@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/dist/index.d.mts +39 -17
- package/dist/index.mjs +1 -2180
- package/package.json +4 -5
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/src/components.ts +0 -258
- package/src/delta.ts +0 -400
- package/src/diff.ts +0 -494
- package/src/errors.ts +0 -51
- package/src/index.ts +0 -57
- package/src/match.ts +0 -797
- package/src/signature.ts +0 -53
- package/src/similarity.ts +0 -414
- package/src/slice.ts +0 -327
- package/src/status.ts +0 -36
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
|
-
}
|