@aburi/diff 0.3.0 → 0.4.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/signature.ts DELETED
@@ -1,53 +0,0 @@
1
- import type { Signature } from "@aburi/types"
2
-
3
- /**
4
- * §3.4.2 signatureSimilarity — averaged type-similarity across `inputs`, `outputs`, and
5
- * `throws`. Follows diff-algorithm.md §3.4.2:
6
- * - null + null → 1.0 (both symbols are signature-less; nothing to compare)
7
- * - null + non-null (either side) → 0.0
8
- * - both non-null: mean of three subscores
9
- *
10
- * Subscores:
11
- * - inputs: ordered type-string equality rate (`i-th type equal` counts as 1, else 0)
12
- * - outputs: same shape, but outputs is an array of type strings
13
- * - throws: unordered set intersection over union
14
- *
15
- * Empty subarrays on both sides count as 1.0 (they are trivially equal); on one side only
16
- * they count as 0.0 (asymmetric information).
17
- */
18
- export function signatureSimilarity(
19
- base: Signature | null | undefined,
20
- head: Signature | null | undefined,
21
- ): number {
22
- const baseSig = base ?? null
23
- const headSig = head ?? null
24
- if (baseSig === null && headSig === null) return 1
25
- if (baseSig === null || headSig === null) return 0
26
- const inputsScore = compareOrderedTypes(
27
- baseSig.inputs.map((i) => i.type),
28
- headSig.inputs.map((i) => i.type),
29
- )
30
- const outputsScore = compareOrderedTypes(baseSig.outputs, headSig.outputs)
31
- const throwsScore = compareUnorderedSet(baseSig.throws, headSig.throws)
32
- return (inputsScore + outputsScore + throwsScore) / 3
33
- }
34
-
35
- function compareOrderedTypes(a: readonly string[], b: readonly string[]): number {
36
- if (a.length === 0 && b.length === 0) return 1
37
- if (a.length !== b.length) return 0
38
- let matches = 0
39
- for (let i = 0; i < a.length; i++) {
40
- if (a[i] === b[i]) matches++
41
- }
42
- return matches / a.length
43
- }
44
-
45
- function compareUnorderedSet(a: readonly string[], b: readonly string[]): number {
46
- if (a.length === 0 && b.length === 0) return 1
47
- const setA = new Set(a)
48
- const setB = new Set(b)
49
- let intersection = 0
50
- for (const t of setA) if (setB.has(t)) intersection++
51
- const union = setA.size + setB.size - intersection
52
- return union === 0 ? 1 : intersection / union
53
- }
package/src/similarity.ts DELETED
@@ -1,414 +0,0 @@
1
- /**
2
- * String similarity utilities dedicated to the diff engine. Kept in one file so the
3
- * name/signature/owner formulas are auditable side by side against diff-algorithm.md §3.4.
4
- */
5
-
6
- /**
7
- * Tokenise a qualified name into normalised lowercase word segments. Splits on:
8
- * - camelCase / PascalCase boundaries (`fooBar` → `foo`, `Bar`)
9
- * - snake_case underscores (`foo_bar` → `foo`, `bar`)
10
- * - dotted namespaces (`ns.Class` → `ns`, `Class`)
11
- * - `::` static-method separator (`Class::method` → `Class`, `method`)
12
- * - runs of digits (`get2Users` → `get`, `2`, `Users`)
13
- *
14
- * The result is de-duplicated so `InvoiceService.createInvoice` collapses to
15
- * `["invoice", "service", "create"]` — Jaccard similarity relies on set semantics, so
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.
26
- */
27
- export function tokenizeName(input: string): string[] {
28
- const seen = new Set<string>()
29
- const out: string[] = []
30
- for (const raw of input.split(/[.:_\-\s/]+/)) {
31
- if (raw.length === 0) continue
32
- for (const piece of splitCamel(raw)) {
33
- const norm = piece.toLowerCase()
34
- if (norm.length === 0 || seen.has(norm)) continue
35
- seen.add(norm)
36
- out.push(norm)
37
- }
38
- }
39
- return out
40
- }
41
-
42
- function splitCamel(word: string): string[] {
43
- const chunks: string[] = []
44
- let current = ""
45
- for (const ch of word) {
46
- if (current === "") {
47
- current = ch
48
- continue
49
- }
50
- const prev = current.slice(-1)
51
- const boundary = isCamelBoundary(prev, ch)
52
- if (boundary) {
53
- chunks.push(current)
54
- current = ch
55
- } else {
56
- current += ch
57
- }
58
- }
59
- if (current.length > 0) chunks.push(current)
60
- return chunks
61
- }
62
-
63
- function isCamelBoundary(prev: string, curr: string): boolean {
64
- const prevLower = prev >= "a" && prev <= "z"
65
- const prevUpper = prev >= "A" && prev <= "Z"
66
- const prevDigit = prev >= "0" && prev <= "9"
67
- const currLower = curr >= "a" && curr <= "z"
68
- const currUpper = curr >= "A" && curr <= "Z"
69
- const currDigit = curr >= "0" && curr <= "9"
70
- if (prevLower && currUpper) return true
71
- if ((prevLower || prevUpper) && currDigit) return true
72
- if (prevDigit && (currLower || currUpper)) return true
73
- return false
74
- }
75
-
76
- /**
77
- * Jaccard similarity between two token multisets. Formula: |A ∩ B| / |A ∪ B|.
78
- * Empty on both sides returns 1.0 (both are "no tokens", which are equivalent for the
79
- * purpose of §3.4.1). Empty on one side only returns 0.0.
80
- */
81
- export function jaccard(a: readonly string[], b: readonly string[]): number {
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]
89
- let intersection = 0
90
- for (const token of small) if (large.has(token)) intersection++
91
- return intersection / (a.size + b.size - intersection)
92
- }
93
-
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
- */
99
- export function jaccardTokens(a: string, b: string): number {
100
- return jaccard(tokenizeName(a), tokenizeName(b))
101
- }
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
-
108
- /**
109
- * §3.4.1 — Jaccard over the tokens of the full qualified name. The last segment is not
110
- * given extra weight; the entire path contributes uniformly because the intersection is
111
- * what the score should reward.
112
- */
113
- export function nameSimilarity(baseName: string, headName: string): number {
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)
124
- }
125
-
126
- /**
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.
135
- *
136
- * Owner extraction:
137
- * - `Class::method` → `Class`
138
- * - `A.B.C.method` → `A.B.C`
139
- * - `topLevel` → `` (empty)
140
- *
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`.
203
- */
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 {
221
- const baseOwner = extractOwner(baseName)
222
- const headOwner = extractOwner(headName)
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
- }
397
- }
398
-
399
- function extractOwner(qname: string): string {
400
- const staticIdx = qname.indexOf("::")
401
- if (staticIdx >= 0) return qname.slice(0, staticIdx)
402
- const lastDot = qname.lastIndexOf(".")
403
- if (lastDot >= 0) return qname.slice(0, lastDot)
404
- return ""
405
- }
406
-
407
- /** §3.4.1 tail note — used by drop-4.5 weak matcher and threshold lookup. */
408
- export function lastSegment(qname: string): string {
409
- const staticIdx = qname.indexOf("::")
410
- if (staticIdx >= 0) return qname.slice(staticIdx + 2)
411
- const lastDot = qname.lastIndexOf(".")
412
- if (lastDot >= 0) return qname.slice(lastDot + 1)
413
- return qname
414
- }