@aburi/diff 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/similarity.ts CHANGED
@@ -14,6 +14,15 @@
14
14
  * The result is de-duplicated so `InvoiceService.createInvoice` collapses to
15
15
  * `["invoice", "service", "create"]` — Jaccard similarity relies on set semantics, so
16
16
  * repeated tokens must not double-count.
17
+ *
18
+ * **The camel boundary is ASCII.** `isCamelBoundary` compares code points against `a`–`z`,
19
+ * `A`–`Z` and `0`–`9`, so a name written in a script with no ASCII case boundary and no
20
+ * separator comes back whole: `获取用户信息` and `ユーザー情報を取得する` are one token each,
21
+ * and so is `получитьПользователя` — its camel hump does not register. A separator still
22
+ * splits (`ユーザー.取得` gives two), and a mixed name splits on its ASCII half.
23
+ *
24
+ * That makes the token count a poor measure of how much such a name says, which matters
25
+ * wherever a count is read as a proxy for that — see §3.4.3's admissibility rule.
17
26
  */
18
27
  export function tokenizeName(input: string): string[] {
19
28
  const seen = new Set<string>()
@@ -70,50 +79,321 @@ function isCamelBoundary(prev: string, curr: string): boolean {
70
79
  * purpose of §3.4.1). Empty on one side only returns 0.0.
71
80
  */
72
81
  export function jaccard(a: readonly string[], b: readonly string[]): number {
73
- if (a.length === 0 && b.length === 0) return 1
74
- if (a.length === 0 || b.length === 0) return 0
75
- const setA = new Set(a)
76
- const setB = new Set(b)
82
+ return jaccardSets(new Set(a), new Set(b))
83
+ }
84
+
85
+ function jaccardSets(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
86
+ if (a.size === 0 && b.size === 0) return 1
87
+ if (a.size === 0 || b.size === 0) return 0
88
+ const [small, large] = a.size <= b.size ? [a, b] : [b, a]
77
89
  let intersection = 0
78
- for (const t of setA) if (setB.has(t)) intersection++
79
- const union = setA.size + setB.size - intersection
80
- return intersection / union
90
+ for (const token of small) if (large.has(token)) intersection++
91
+ return intersection / (a.size + b.size - intersection)
81
92
  }
82
93
 
83
- /** name-token Jaccard shortcut used by nameSimilarity and ownerSimilarity call sites. */
94
+ /**
95
+ * Jaccard over the tokens of two strings. Part of the module's surface rather than an
96
+ * internal shortcut: the two formulas below go through a token table they share for one
97
+ * matching pass, so this has no caller inside this file.
98
+ */
84
99
  export function jaccardTokens(a: string, b: string): number {
85
100
  return jaccard(tokenizeName(a), tokenizeName(b))
86
101
  }
87
102
 
103
+ /** How a formula gets the token set of a name. The only thing the memo below changes. */
104
+ type TokenSetOf = (value: string) => ReadonlySet<string>
105
+
106
+ const tokenizeEveryTime: TokenSetOf = (value) => new Set(tokenizeName(value))
107
+
88
108
  /**
89
109
  * §3.4.1 — Jaccard over the tokens of the full qualified name. The last segment is not
90
110
  * given extra weight; the entire path contributes uniformly because the intersection is
91
111
  * what the score should reward.
92
112
  */
93
113
  export function nameSimilarity(baseName: string, headName: string): number {
94
- return jaccardTokens(baseName, headName)
114
+ return nameFormula(tokenizeEveryTime, baseName, headName)
115
+ }
116
+
117
+ /**
118
+ * §3.4.1 — Jaccard over the tokens of the **last segment**: the member name with its owner
119
+ * removed. What §3.4's composite reads, because §3.4.6 decides the owner separately and
120
+ * reading it on both axes charges for it twice.
121
+ */
122
+ export function memberSimilarity(baseName: string, headName: string): number {
123
+ return memberFormula(tokenizeEveryTime, baseName, headName)
95
124
  }
96
125
 
97
126
  /**
98
- * §3.4.6 — owner similarity: same as nameSimilarity but restricted to everything **before**
99
- * the last segment. Compensates for the R-8 same-name-different-class collision, where
100
- * `UserRepo.getUser` should not be pair-swapped with `AdminRepo.getUser`.
127
+ * §3.4.6 (R-8) — whether two Symbols are close enough in *scope* to be the same Symbol: the
128
+ * same owner, or one whose owner was renamed. A gate rather than a score, because grading the
129
+ * owner cannot do what R-8 asks. `UserRepo.findById` and `AdminRepo.findById` agree on their
130
+ * member name and their signature, so a shared `Repo` token at weight 0.2 carries them to
131
+ * 0.8667 against a 0.85 threshold — while `UsersRepository.findById`, which *is* the rename,
132
+ * shares no owner token and scores 0.8000. The collision outscores the rename, and raising the
133
+ * weight only moves both: at 0.3 two three-token class names sharing two tokens land on
134
+ * exactly 0.85.
101
135
  *
102
136
  * Owner extraction:
103
137
  * - `Class::method` → `Class`
104
138
  * - `A.B.C.method` → `A.B.C`
105
139
  * - `topLevel` → `` (empty)
106
140
  *
107
- * When both owners are empty (top-level functions), returns 1.0 because both live in the
108
- * same "no-owner" bucket. When one is empty and the other is not, returns 0.0 because the
109
- * two Symbols live in structurally different scopes.
141
+ * Two empty owners are compatible: top-level Symbols share the one outer scope. One empty and
142
+ * one not are never compatible — the two live at different depths.
143
+ *
144
+ * Otherwise the owners must correspond segment for segment, and within a segment every token
145
+ * on each side must find a distinct partner on the other under `sameWord`. Both sides must be
146
+ * covered, so `UserRepo` and `UserRepoV2` are two classes rather than one renamed — an added
147
+ * token is as much evidence of a sibling as of a rename, and R-8's business is refusing the
148
+ * collision.
149
+ */
150
+ export function ownersAreCompatible(baseName: string, headName: string): boolean {
151
+ return ownerGate(tokenizeEveryTime, baseName, headName)
152
+ }
153
+
154
+ /**
155
+ * §3.4.6 — the tokens `a` and `b` name the same thing.
156
+ *
157
+ * Equal, or the same word inflected: `user`/`users`, `entity`/`entities`. Nothing else, and
158
+ * that is the whole of the rule rather than a first approximation.
159
+ *
160
+ * A bare prefix test is the obvious generalisation and it cannot be made to work. It admits
161
+ * `repo`/`report`, `cache`/`cached`, `con`/`controller` — two distinct classes, which is
162
+ * exactly the collision R-8 exists to refuse. Every measure that might separate those from a
163
+ * real rename fails, because the two populations interleave rather than sitting on opposite
164
+ * sides of anything:
165
+ *
166
+ * ```
167
+ * dice levenshtein
168
+ * accept UserRepo/UsersRepository 0.571 7
169
+ * reject RepoManager/ReportManager 0.818 2
170
+ * accept Repo/Repository 0.500 6
171
+ * reject CacheStore/CachedStore 0.842 1
172
+ * ```
173
+ *
174
+ * The renames to accept score *lower* than the collisions to refuse, on both. There is no
175
+ * threshold, and a length-growth rule fares no better: `con`→`controller` grows by 7 and
176
+ * `user`→`users` by 1, so anything admitting the second admits the first.
177
+ *
178
+ * Inflection is not on that spectrum. It is a closed, mechanical relation between two spellings
179
+ * of one word, so it can be recognised rather than estimated — and it covers the case a plain
180
+ * equality test misses most often, a class pluralised in place.
181
+ *
182
+ * What this gives up is the abbreviation family: `UserRepo` → `UsersRepository` no longer
183
+ * clears the gate on `repo`/`repository`. That is a real rename reported as `added` +
184
+ * `removed`, and §3.4.6 records it as the price of refusing `repo`/`report`, which no rule
185
+ * over the two strings alone can tell apart. The evidence that would settle it is not in the
186
+ * strings: the owner is itself a Symbol, and whether *it* paired is the question being
187
+ * guessed at here.
188
+ */
189
+ function sameWord(a: string, b: string): boolean {
190
+ if (a === b) return true
191
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a]
192
+ return pluralises(short, long)
193
+ }
194
+
195
+ /**
196
+ * The longest owner segment the matching will search.
197
+ *
198
+ * `augment` is Kuhn's, so the search is cubic in the token count and recursive with it. A
199
+ * class name is a handful of words; anything past this is not a name the gate has an opinion
200
+ * about, and `buildDiff` takes IR JSON from a caller rather than only from `aburi scan`, so
201
+ * the bound is what turns an adversarial 1600-token identifier from a hang into an answer.
202
+ * Refusing is the safe direction: the pair falls to `added` + `removed`.
110
203
  */
111
- export function ownerSimilarity(baseName: string, headName: string): number {
204
+ const MAX_OWNER_SEGMENT_TOKENS = 32
205
+
206
+ /** English noun inflection, which is what a pluralised class name goes through. */
207
+ function pluralises(singular: string, plural: string): boolean {
208
+ if (plural === `${singular}s` || plural === `${singular}es`) return true
209
+ return singular.endsWith("y") && plural === `${singular.slice(0, -1)}ies`
210
+ }
211
+
212
+ function nameFormula(setOf: TokenSetOf, baseName: string, headName: string): number {
213
+ return jaccardSets(setOf(baseName), setOf(headName))
214
+ }
215
+
216
+ function memberFormula(setOf: TokenSetOf, baseName: string, headName: string): number {
217
+ return jaccardSets(setOf(lastSegment(baseName)), setOf(lastSegment(headName)))
218
+ }
219
+
220
+ function ownerGate(setOf: TokenSetOf, baseName: string, headName: string): boolean {
112
221
  const baseOwner = extractOwner(baseName)
113
222
  const headOwner = extractOwner(headName)
114
- if (baseOwner === "" && headOwner === "") return 1
115
- if (baseOwner === "" || headOwner === "") return 0
116
- return jaccardTokens(baseOwner, headOwner)
223
+ if (baseOwner === "" && headOwner === "") return true
224
+ if (baseOwner === "" || headOwner === "") return false
225
+ // The common case, and the one a bucket of methods on one class hits every time.
226
+ if (baseOwner === headOwner) return true
227
+ return segmentsCorrespond(baseOwner, headOwner, setOf)
228
+ }
229
+
230
+ /**
231
+ * §3.4.6 — the two owners name the same scope: segment for segment, token for token.
232
+ *
233
+ * Compared per segment rather than over the owner as a whole, because `tokenizeName` dedups
234
+ * and an owner is a *path*. `Users.UserRepo` collapses to `{users, user, repo}` while
235
+ * `Users.UsersRepository` collapses to `{users, repository}` — the namespace and the class
236
+ * share a word, so one side loses a token and the two stop being comparable before any
237
+ * spelling is looked at. Segment by segment they line up: `Users`/`Users`, then
238
+ * `UserRepo`/`UsersRepository`.
239
+ *
240
+ * A rename changes what a class is called, not how deeply it is nested, so a differing segment
241
+ * count is a differing scope.
242
+ */
243
+ function segmentsCorrespond(baseOwner: string, headOwner: string, setOf: TokenSetOf): boolean {
244
+ const baseSegments = baseOwner.split(".")
245
+ const headSegments = headOwner.split(".")
246
+ if (baseSegments.length !== headSegments.length) return false
247
+ return baseSegments.every((segment, index) => {
248
+ const counterpart = headSegments[index]
249
+ if (counterpart === undefined) return false
250
+ if (segment === counterpart) return true
251
+ return coversBothWays(setOf(segment), setOf(counterpart))
252
+ })
253
+ }
254
+
255
+ /**
256
+ * Whether the two token sets admit a perfect matching under `sameWord`.
257
+ *
258
+ * Sizes must agree, after which an injection from one side is a bijection — so only one
259
+ * direction is searched. The search is augmenting-path rather than greedy, because a greedy
260
+ * pass can strand a token a different choice would have matched: over `{user, users}` and
261
+ * `{user, userx}`, taking the identical pair first leaves `users` with nothing, though
262
+ * `user`→`userx` with `users`→`user` covers both. One segment runs to a handful of tokens, so
263
+ * the exact answer costs nothing worth saving.
264
+ */
265
+ function coversBothWays(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
266
+ if (a.size !== b.size) return false
267
+ if (a.size > MAX_OWNER_SEGMENT_TOKENS) return false
268
+ const right = [...b]
269
+ const partnerOf = new Map<string, string>()
270
+ for (const token of a) {
271
+ if (!augment(token, right, partnerOf, new Set())) return false
272
+ }
273
+ return true
274
+ }
275
+
276
+ /** Kuhn's step: claim a free partner for `token`, or displace one that can move on. */
277
+ function augment(
278
+ token: string,
279
+ right: readonly string[],
280
+ partnerOf: Map<string, string>,
281
+ visited: Set<string>,
282
+ ): boolean {
283
+ for (const candidate of right) {
284
+ if (visited.has(candidate) || !sameWord(token, candidate)) continue
285
+ visited.add(candidate)
286
+ const holder = partnerOf.get(candidate)
287
+ if (holder === undefined || augment(holder, right, partnerOf, visited)) {
288
+ partnerOf.set(candidate, token)
289
+ return true
290
+ }
291
+ }
292
+ return false
293
+ }
294
+
295
+ /**
296
+ * §3.4.6 — a necessary condition on two non-empty owners, cheaper than the full gate: their
297
+ * first segments must correspond, since segments are compared positionally.
298
+ *
299
+ * A filter, not a decision. It relaxes `coversBothWays`' perfect matching to "every token
300
+ * finds some partner", so it can only ever admit more than the gate does; answering `true`
301
+ * says the gate has to be asked. Loosening it further costs correctness nothing and speed a
302
+ * little — refusing something the gate would accept is the direction that would break recall.
303
+ */
304
+ function firstSegmentsCouldAgree(baseOwner: string, headOwner: string, setOf: TokenSetOf): boolean {
305
+ const baseFirst = baseOwner.slice(0, dotOrEnd(baseOwner))
306
+ const headFirst = headOwner.slice(0, dotOrEnd(headOwner))
307
+ if (baseFirst === headFirst) return true
308
+ const baseTokens = setOf(baseFirst)
309
+ const headTokens = setOf(headFirst)
310
+ if (baseTokens.size !== headTokens.size) return false
311
+ // Still a necessary condition at the ceiling, because `coversBothWays` refuses a segment
312
+ // this wide outright — and refusing here keeps that an O(1) answer rather than an all-pairs
313
+ // scan run ahead of it, which is the adversarial input the ceiling exists for.
314
+ if (baseTokens.size > MAX_OWNER_SEGMENT_TOKENS) return false
315
+ for (const token of baseTokens) {
316
+ let partnered = false
317
+ for (const candidate of headTokens) {
318
+ if (sameWord(token, candidate)) {
319
+ partnered = true
320
+ break
321
+ }
322
+ }
323
+ if (!partnered) return false
324
+ }
325
+ return true
326
+ }
327
+
328
+ function dotOrEnd(owner: string): number {
329
+ const dot = owner.indexOf(".")
330
+ return dot < 0 ? owner.length : dot
331
+ }
332
+
333
+ /** The formulas §3.4 reads, over a token table shared for one matching pass. */
334
+ export interface NameScorer {
335
+ name(baseName: string, headName: string): number
336
+ member(baseName: string, headName: string): number
337
+ ownersCompatible(baseName: string, headName: string): boolean
338
+ }
339
+
340
+ /**
341
+ * A scorer that tokenises each distinct name once.
342
+ *
343
+ * Stage 4 scores every (base, head) pair in a bucket, so a bucket of K on each side asks for
344
+ * K² similarities over 2K distinct names — tokenising on every call splits the same strings
345
+ * into the same tokens hundreds of thousands of times. The table lives for one call rather
346
+ * than for the process, because the names it holds are only the ones that pass is comparing.
347
+ */
348
+ export function createNameScorer(): NameScorer {
349
+ const sets = new Map<string, ReadonlySet<string>>()
350
+ const setOf: TokenSetOf = (value) => {
351
+ const cached = sets.get(value)
352
+ if (cached !== undefined) return cached
353
+ const built: ReadonlySet<string> = new Set(tokenizeName(value))
354
+ sets.set(value, built)
355
+ return built
356
+ }
357
+ const owners = new Map<string, string>()
358
+ const ownerOf = (qname: string): string => {
359
+ const cached = owners.get(qname)
360
+ if (cached !== undefined) return cached
361
+ const built = extractOwner(qname)
362
+ owners.set(qname, built)
363
+ return built
364
+ }
365
+ /**
366
+ * §3.4.6's gate, with the segment split and the matching skipped where they can be.
367
+ *
368
+ * The gate is the costliest thing stage 4 asks per candidate — it splits both owners into
369
+ * segments, tokenises each and runs an augmenting-path matching. Two shortcuts, in order of
370
+ * how much they save:
371
+ *
372
+ * - **Identical owners.** A bucket of methods on one class asks about the same owner over
373
+ * and over, and `ownerGate` already answers those on a string compare; doing it here skips
374
+ * the two `extractOwner` calls as well, which are substrings on every candidate.
375
+ * - **A different first segment.** Segments correspond positionally, so owners whose first
376
+ * segments cannot be the same word are incompatible whatever the rest says. The check is
377
+ * one `sameWord` over two token sets the table already holds, against a full matching over
378
+ * every segment.
379
+ *
380
+ * Not memoised on the pair. A bulk rename produces as many distinct owner pairs as
381
+ * candidates, so a table keyed on them grows with the cross-product — enough to exceed
382
+ * V8's Map limit on the very input this is meant to make fast.
383
+ */
384
+ const compatible = (baseName: string, headName: string): boolean => {
385
+ const baseOwner = ownerOf(baseName)
386
+ const headOwner = ownerOf(headName)
387
+ if (baseOwner === headOwner) return true
388
+ if (baseOwner === "" || headOwner === "") return false
389
+ if (!firstSegmentsCouldAgree(baseOwner, headOwner, setOf)) return false
390
+ return segmentsCorrespond(baseOwner, headOwner, setOf)
391
+ }
392
+ return {
393
+ name: (baseName, headName) => nameFormula(setOf, baseName, headName),
394
+ member: (baseName, headName) => memberFormula(setOf, baseName, headName),
395
+ ownersCompatible: compatible,
396
+ }
117
397
  }
118
398
 
119
399
  function extractOwner(qname: string): string {
package/src/slice.ts CHANGED
@@ -278,6 +278,12 @@ function nodeIdOf(change: SymbolChange): SymbolId | null {
278
278
  case "removed":
279
279
  // §4.1 — removed uses base-side id because there is no head symbol.
280
280
  return change.symbol.id
281
+ case "unknown":
282
+ // The one side that exists carries the id, as for added / removed. An unknown entry
283
+ // is a thing a reviewer has to look at, so it belongs in the cluster its neighbours
284
+ // are in — a Symbol whose file went missing is most useful read beside the callers
285
+ // that still reference it.
286
+ return change.symbol.id
281
287
  case "changed":
282
288
  case "moved+changed":
283
289
  case "dropped-toggled":