@aburi/diff 0.1.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/LICENSE +202 -0
- package/README.md +4 -4
- package/dist/index.d.mts +294 -42
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1408 -269
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -4
- package/src/components.ts +166 -13
- package/src/delta.ts +129 -30
- package/src/diff.ts +295 -38
- package/src/errors.ts +16 -0
- package/src/index.ts +13 -1
- package/src/match.ts +634 -175
- package/src/similarity.ts +299 -19
- package/src/slice.ts +327 -0
- package/src/status.ts +2 -2
package/src/match.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { trySymbolId } from "@aburi/core"
|
|
2
|
+
import type { Symbol as IRSymbol, MatchRationale, SymbolId } from "@aburi/types"
|
|
2
3
|
import { signatureSimilarity } from "./signature"
|
|
3
|
-
import {
|
|
4
|
+
import { createNameScorer, lastSegment, type NameScorer, tokenizeName } from "./similarity"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* A concluded pairing of one base Symbol with one head Symbol, along with the
|
|
@@ -19,9 +20,89 @@ export type GitRenameMap = ReadonlyMap<string, string>
|
|
|
19
20
|
/** Fingerprint for the 12-hex-char "everything zero" (i.e. dropped) canonical hash. */
|
|
20
21
|
const ZERO_LOGIC_FP = "000000000000"
|
|
21
22
|
|
|
23
|
+
/** §3.3 — the similarity a multi-candidate logic-fingerprint group must reach to pair. */
|
|
24
|
+
const NAME_DISAMBIGUATION_THRESHOLD = 0.85
|
|
25
|
+
|
|
26
|
+
/** One candidate pairing and the score that ranks it against the others. */
|
|
27
|
+
interface ScoredPair {
|
|
28
|
+
base: IRSymbol
|
|
29
|
+
head: IRSymbol
|
|
30
|
+
score: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What one sweep settled: the pairings, and the ids each side spent on them. */
|
|
34
|
+
interface Assignment {
|
|
35
|
+
pairs: readonly ScoredPair[]
|
|
36
|
+
baseIds: ReadonlySet<SymbolId>
|
|
37
|
+
headIds: ReadonlySet<SymbolId>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* §3.8 — accept candidate pairings highest score first, taking each base and each head at
|
|
42
|
+
* most once. Stages 3, 4 and 4.5 all choose from a set of possible pairings, and all three
|
|
43
|
+
* used to decide one head at a time — which let an earlier head consume a base that was a
|
|
44
|
+
* later head's exact match, and reported the loser as `added` beside the pair it lost.
|
|
45
|
+
*
|
|
46
|
+
* The two id sets come back with the pairings because every caller needs them to work out
|
|
47
|
+
* what is left over, and deriving them again at the call site is a chance to derive the
|
|
48
|
+
* wrong one: reading the head ids out as the base ids typechecks, and reproduces the very
|
|
49
|
+
* symptom this exists to prevent.
|
|
50
|
+
*
|
|
51
|
+
* Not free, and §8.2 carries the bound: `candidates` is every pairing that clears its
|
|
52
|
+
* threshold, so this holds O(base × head) records for stages 3 and 4 in the worst case where
|
|
53
|
+
* the per-head loop it replaced held one, and sorting them is O(C log C). Stage 4 buys that
|
|
54
|
+
* back and more with `createNameScorer`.
|
|
55
|
+
*
|
|
56
|
+
* It is not an optimal assignment — a greedy sweep can still strand a pair whose partners
|
|
57
|
+
* were both taken by higher-scoring ones — but it never passes over the best available
|
|
58
|
+
* pairing, which is the case that shows up in a review as one symbol appearing twice.
|
|
59
|
+
*
|
|
60
|
+
* Stage 4.5 does not call this, because that licence is what it cannot borrow: §3.4.5's
|
|
61
|
+
* candidates are all worth the same, so no pairing is the best available and stranding one
|
|
62
|
+
* buys nothing. See `bestPairing`.
|
|
63
|
+
*/
|
|
64
|
+
function acceptInScoreOrder(candidates: ScoredPair[]): Assignment {
|
|
65
|
+
candidates.sort(compareCandidates)
|
|
66
|
+
const baseIds = new Set<SymbolId>()
|
|
67
|
+
const headIds = new Set<SymbolId>()
|
|
68
|
+
const pairs: ScoredPair[] = []
|
|
69
|
+
for (const candidate of candidates) {
|
|
70
|
+
if (baseIds.has(candidate.base.id) || headIds.has(candidate.head.id)) continue
|
|
71
|
+
baseIds.add(candidate.base.id)
|
|
72
|
+
headIds.add(candidate.head.id)
|
|
73
|
+
pairs.push(candidate)
|
|
74
|
+
}
|
|
75
|
+
return { pairs, baseIds, headIds }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Highest score first, then `(base.id, head.id)` ascending. The two id keys are what make
|
|
80
|
+
* the order total, and they are a total order only because ids are unique within a Document
|
|
81
|
+
* (ir-schema.md §14 #1), which `buildDiff` establishes before the first stage runs. Without
|
|
82
|
+
* them equal scores resolve to enumeration order, and the diff stops being a function of the
|
|
83
|
+
* two Documents.
|
|
84
|
+
*/
|
|
85
|
+
function compareCandidates(a: ScoredPair, b: ScoredPair): number {
|
|
86
|
+
if (a.score !== b.score) return b.score - a.score
|
|
87
|
+
if (a.base.id !== b.base.id) return a.base.id < b.base.id ? -1 : 1
|
|
88
|
+
return a.head.id < b.head.id ? -1 : a.head.id > b.head.id ? 1 : 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The entries of `symbols` that no accepted pairing claimed, in their original order. */
|
|
92
|
+
function unclaimed(symbols: readonly IRSymbol[], claimed: ReadonlySet<SymbolId>): IRSymbol[] {
|
|
93
|
+
return symbols.filter((symbol) => !claimed.has(symbol.id))
|
|
94
|
+
}
|
|
95
|
+
|
|
22
96
|
/**
|
|
23
97
|
* §3.1 — segments Symbols with identical `id` into paired matches. Runs first because it
|
|
24
98
|
* is the highest-confidence signal (no heuristics, just hash lookup).
|
|
99
|
+
*
|
|
100
|
+
* Assumes `id` is unique on each side (ir-schema.md §14 #1) and does not check it:
|
|
101
|
+
* `buildDiff` establishes that before calling, and a caller reaching this export directly
|
|
102
|
+
* owns the obligation. A repeat on the head side loses all but the last entry to the lookup
|
|
103
|
+
* map and then removes every one of them from `remainingHead`; a repeat on the base side
|
|
104
|
+
* pairs the same head Symbol more than once. The later stages track consumed base Symbols
|
|
105
|
+
* by id and lose a repeat the same way.
|
|
25
106
|
*/
|
|
26
107
|
export function matchStageId(
|
|
27
108
|
base: readonly IRSymbol[],
|
|
@@ -31,11 +112,11 @@ export function matchStageId(
|
|
|
31
112
|
remainingBase: IRSymbol[]
|
|
32
113
|
remainingHead: IRSymbol[]
|
|
33
114
|
} {
|
|
34
|
-
const headById = new Map<
|
|
115
|
+
const headById = new Map<SymbolId, IRSymbol>()
|
|
35
116
|
for (const s of head) headById.set(s.id, s)
|
|
36
117
|
const matched: SymbolPair[] = []
|
|
37
118
|
const remainingBase: IRSymbol[] = []
|
|
38
|
-
const usedHead = new Set<
|
|
119
|
+
const usedHead = new Set<SymbolId>()
|
|
39
120
|
for (const b of base) {
|
|
40
121
|
const h = headById.get(b.id)
|
|
41
122
|
if (h !== undefined) {
|
|
@@ -51,7 +132,9 @@ export function matchStageId(
|
|
|
51
132
|
|
|
52
133
|
/**
|
|
53
134
|
* §3.2 — git rename detection. When a rename map is available (typical of ref-driven
|
|
54
|
-
* scans), rewrite the base id with the head-side file path and look for a hit.
|
|
135
|
+
* scans), rewrite the base id with the head-side file path and look for a hit. Two base
|
|
136
|
+
* files renamed onto one target predict the same head id, so the claimants are collected
|
|
137
|
+
* before one is chosen.
|
|
55
138
|
*
|
|
56
139
|
* The rewriter only touches the `file` portion of the id — the `<language>:` prefix and
|
|
57
140
|
* the trailing `#<qualified-name>` segment stay unchanged so a moved file with the same
|
|
@@ -70,47 +153,78 @@ export function matchStageGitRename(
|
|
|
70
153
|
if (renameMap === null || renameMap.size === 0) {
|
|
71
154
|
return { matched: [], remainingBase: [...remainingBase], remainingHead: [...remainingHead] }
|
|
72
155
|
}
|
|
73
|
-
const headById = new Map<
|
|
156
|
+
const headById = new Map<SymbolId, IRSymbol>()
|
|
74
157
|
for (const h of remainingHead) headById.set(h.id, h)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
158
|
+
|
|
159
|
+
// Collect every base that predicts a given head before choosing, rather than letting the
|
|
160
|
+
// first one in the array take it. Two files renamed onto one target both predict the same
|
|
161
|
+
// id, and which of them is the move source should not be a property of the array order.
|
|
162
|
+
const claimants = new Map<SymbolId, { head: IRSymbol; bases: [IRSymbol, ...IRSymbol[]] }>()
|
|
78
163
|
for (const b of remainingBase) {
|
|
79
164
|
const newPath = renameMap.get(b.source.file)
|
|
80
|
-
if (newPath === undefined)
|
|
81
|
-
remaining.push(b)
|
|
82
|
-
continue
|
|
83
|
-
}
|
|
165
|
+
if (newPath === undefined) continue
|
|
84
166
|
const expectedId = rewriteIdFile(b.id, b.source.file, newPath)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
167
|
+
if (expectedId === null) continue
|
|
168
|
+
const head = headById.get(expectedId)
|
|
169
|
+
if (head === undefined) continue
|
|
170
|
+
const claim = claimants.get(expectedId)
|
|
171
|
+
if (claim === undefined) claimants.set(expectedId, { head, bases: [b] })
|
|
172
|
+
else claim.bases.push(b)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const matched: SymbolPair[] = []
|
|
176
|
+
const usedBase = new Set<SymbolId>()
|
|
177
|
+
const usedHead = new Set<SymbolId>()
|
|
178
|
+
for (const { head, bases } of claimants.values()) {
|
|
179
|
+
const base = lowestId(bases)
|
|
180
|
+
matched.push({ base, head, rationale: "git-rename" })
|
|
181
|
+
usedBase.add(base.id)
|
|
182
|
+
usedHead.add(head.id)
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
matched,
|
|
186
|
+
remainingBase: unclaimed(remainingBase, usedBase),
|
|
187
|
+
remainingHead: unclaimed(remainingHead, usedHead),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Total on the non-empty claim lists above: a claim is created holding its first base. */
|
|
192
|
+
function lowestId(symbols: readonly [IRSymbol, ...IRSymbol[]]): IRSymbol {
|
|
193
|
+
let lowest = symbols[0]
|
|
194
|
+
for (const symbol of symbols) {
|
|
195
|
+
if (symbol.id < lowest.id) lowest = symbol
|
|
92
196
|
}
|
|
93
|
-
|
|
94
|
-
return { matched, remainingBase: remaining, remainingHead: remainingHeadOut }
|
|
197
|
+
return lowest
|
|
95
198
|
}
|
|
96
199
|
|
|
97
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Predict the id a base Symbol would carry after git moved its file. The second Symbol-id
|
|
202
|
+
* derivation in the codebase after `makeSymbolId` itself, so it goes back through the same
|
|
203
|
+
* constructor rather than re-concatenating the parts: `trySymbolId` rejects a rename target
|
|
204
|
+
* the id grammar cannot express (a backslash path, say) instead of minting an id no head
|
|
205
|
+
* Symbol can ever equal.
|
|
206
|
+
*
|
|
207
|
+
* Returns `null` for that case, and the unchanged id when no rename applies — the caller
|
|
208
|
+
* treats a null the same way it treats a lookup miss, leaving the pair for stage 3.
|
|
209
|
+
*/
|
|
210
|
+
function rewriteIdFile(id: SymbolId, oldPath: string, newPath: string): SymbolId | null {
|
|
98
211
|
const colon = id.indexOf(":")
|
|
99
212
|
const hash = id.indexOf("#")
|
|
100
213
|
if (colon < 0 || hash < 0 || hash < colon) return id
|
|
101
|
-
const language = id.slice(0, colon)
|
|
102
214
|
const filePart = id.slice(colon + 1, hash)
|
|
103
215
|
if (filePart !== oldPath) return id
|
|
104
|
-
|
|
105
|
-
|
|
216
|
+
return trySymbolId({
|
|
217
|
+
language: id.slice(0, colon),
|
|
218
|
+
file: newPath,
|
|
219
|
+
qualifiedName: id.slice(hash + 1),
|
|
220
|
+
})
|
|
106
221
|
}
|
|
107
222
|
|
|
108
223
|
/**
|
|
109
|
-
* §3.3 — group
|
|
110
|
-
*
|
|
111
|
-
* -
|
|
112
|
-
*
|
|
113
|
-
* sides in remaining for stage 4 to re-evaluate
|
|
224
|
+
* §3.3 — group both sides by `fingerprint.logic` and pair within each group. Two branches:
|
|
225
|
+
* - single base candidate → paired with `logic-fingerprint`, no similarity test
|
|
226
|
+
* - several → `nameSimilarity` disambiguates at ≥ 0.85; a group that cannot reach it is left
|
|
227
|
+
* whole for stage 4 to re-evaluate
|
|
114
228
|
*
|
|
115
229
|
* Dropped symbols are excluded (§3.3 tail) — their logic fingerprint is the sentinel
|
|
116
230
|
* `"000000000000"` and would collide with every other dropped Symbol in the workspace.
|
|
@@ -124,64 +238,105 @@ export function matchStageLogicFingerprint(
|
|
|
124
238
|
remainingBase: IRSymbol[]
|
|
125
239
|
remainingHead: IRSymbol[]
|
|
126
240
|
} {
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
241
|
+
const baseGroups = groupByLogic(remainingBase)
|
|
242
|
+
const headGroups = groupByLogic(remainingHead)
|
|
243
|
+
const scorer = createNameScorer()
|
|
244
|
+
const matched: SymbolPair[] = []
|
|
245
|
+
for (const [logic, heads] of headGroups) {
|
|
246
|
+
const bases = baseGroups.get(logic)
|
|
247
|
+
if (bases === undefined) continue
|
|
248
|
+
matched.push(...pairWithinLogicGroup(bases, heads, scorer))
|
|
249
|
+
}
|
|
250
|
+
const usedBase = new Set(matched.map((pair) => pair.base.id))
|
|
251
|
+
const usedHead = new Set(matched.map((pair) => pair.head.id))
|
|
252
|
+
return {
|
|
253
|
+
matched,
|
|
254
|
+
remainingBase: unclaimed(remainingBase, usedBase),
|
|
255
|
+
remainingHead: unclaimed(remainingHead, usedHead),
|
|
137
256
|
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Symbols by logic fingerprint, skipping the ones §3.3 excludes. Both sides are grouped, so
|
|
261
|
+
* a group is a self-contained problem: the Symbols in it can only pair with each other, and
|
|
262
|
+
* no group’s outcome depends on any other’s.
|
|
263
|
+
*/
|
|
264
|
+
function groupByLogic(symbols: readonly IRSymbol[]): Map<string, IRSymbol[]> {
|
|
265
|
+
const groups = new Map<string, IRSymbol[]>()
|
|
266
|
+
for (const symbol of symbols) {
|
|
267
|
+
if (symbol.dropped || symbol.fingerprint.logic === ZERO_LOGIC_FP) continue
|
|
268
|
+
const bucket = groups.get(symbol.fingerprint.logic) ?? []
|
|
269
|
+
bucket.push(symbol)
|
|
270
|
+
groups.set(symbol.fingerprint.logic, bucket)
|
|
271
|
+
}
|
|
272
|
+
return groups
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* §3.3’s two branches, over one logic-fingerprint group.
|
|
277
|
+
*
|
|
278
|
+
* A lone base candidate pairs with no similarity test — identical logic is taken as proof on
|
|
279
|
+
* its own. With more than one, names disambiguate and a group that cannot reach the
|
|
280
|
+
* threshold is left whole for stage 4.
|
|
281
|
+
*
|
|
282
|
+
* The loop is what keeps the second branch feeding the first: a scored round that consumes
|
|
283
|
+
* all but one base leaves that one unconditional, which is how the per-head version behaved
|
|
284
|
+
* when its candidate list shrank to a single entry.
|
|
285
|
+
*/
|
|
286
|
+
function pairWithinLogicGroup(
|
|
287
|
+
bases: readonly IRSymbol[],
|
|
288
|
+
heads: readonly IRSymbol[],
|
|
289
|
+
scorer: NameScorer,
|
|
290
|
+
): SymbolPair[] {
|
|
291
|
+
let freeBase = [...bases]
|
|
292
|
+
let freeHead = [...heads]
|
|
138
293
|
const matched: SymbolPair[] = []
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
294
|
+
while (freeBase.length > 0 && freeHead.length > 0) {
|
|
295
|
+
const lone = freeBase.length === 1 ? freeBase[0] : undefined
|
|
296
|
+
if (lone !== undefined) {
|
|
297
|
+
const head = closestNameTo(lone, freeHead, scorer)
|
|
298
|
+
if (head === undefined) break
|
|
299
|
+
matched.push({ base: lone, head, rationale: "logic-fingerprint" })
|
|
300
|
+
break
|
|
145
301
|
}
|
|
146
|
-
const candidates =
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const only = candidates[0]
|
|
153
|
-
if (only === undefined) continue
|
|
154
|
-
matched.push({ base: only, head: h, rationale: "logic-fingerprint" })
|
|
155
|
-
usedBaseIds.add(only.id)
|
|
156
|
-
logicMap.delete(h.fingerprint.logic)
|
|
157
|
-
continue
|
|
302
|
+
const candidates: ScoredPair[] = []
|
|
303
|
+
for (const base of freeBase) {
|
|
304
|
+
for (const head of freeHead) {
|
|
305
|
+
const score = scorer.name(base.name, head.name)
|
|
306
|
+
if (score >= NAME_DISAMBIGUATION_THRESHOLD) candidates.push({ base, head, score })
|
|
307
|
+
}
|
|
158
308
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
if (best !== null && best.score >= 0.85) {
|
|
165
|
-
matched.push({
|
|
166
|
-
base: best.symbol,
|
|
167
|
-
head: h,
|
|
168
|
-
rationale: "logic-fingerprint+name-disambiguation",
|
|
169
|
-
})
|
|
170
|
-
usedBaseIds.add(best.symbol.id)
|
|
171
|
-
const remainingBucket = candidates.filter((c) => c.id !== best?.symbol.id)
|
|
172
|
-
if (remainingBucket.length === 0) logicMap.delete(h.fingerprint.logic)
|
|
173
|
-
else logicMap.set(h.fingerprint.logic, remainingBucket)
|
|
174
|
-
} else {
|
|
175
|
-
carryHead.push(h)
|
|
309
|
+
const accepted = acceptInScoreOrder(candidates)
|
|
310
|
+
if (accepted.pairs.length === 0) break
|
|
311
|
+
for (const { base, head } of accepted.pairs) {
|
|
312
|
+
matched.push({ base, head, rationale: "logic-fingerprint+name-disambiguation" })
|
|
176
313
|
}
|
|
314
|
+
freeBase = unclaimed(freeBase, accepted.baseIds)
|
|
315
|
+
freeHead = unclaimed(freeHead, accepted.headIds)
|
|
177
316
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
317
|
+
return matched
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The head whose name is closest to `base`, ties going to the lower id. The lone-candidate
|
|
322
|
+
* branch pairs whatever it is given, so this only decides *which* head it takes — but that
|
|
323
|
+
* decision was array order, and it is visible in the diff.
|
|
324
|
+
*/
|
|
325
|
+
function closestNameTo(
|
|
326
|
+
base: IRSymbol,
|
|
327
|
+
heads: readonly IRSymbol[],
|
|
328
|
+
scorer: NameScorer,
|
|
329
|
+
): IRSymbol | undefined {
|
|
330
|
+
let best: { head: IRSymbol; score: number } | undefined
|
|
331
|
+
for (const head of heads) {
|
|
332
|
+
const score = scorer.name(base.name, head.name)
|
|
333
|
+
if (best === undefined || score > best.score) {
|
|
334
|
+
best = { head, score }
|
|
335
|
+
continue
|
|
182
336
|
}
|
|
337
|
+
if (score === best.score && head.id < best.head.id) best = { head, score }
|
|
183
338
|
}
|
|
184
|
-
return
|
|
339
|
+
return best?.head
|
|
185
340
|
}
|
|
186
341
|
|
|
187
342
|
/**
|
|
@@ -189,17 +344,35 @@ export function matchStageLogicFingerprint(
|
|
|
189
344
|
* `signatureNullness` distinguishes symbols with no signature (`interface`, `type`,
|
|
190
345
|
* `class`) from callable ones so that a class body is never paired against a function.
|
|
191
346
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
347
|
+
* §3.4.6's owner gate runs before the score: a pair whose owners are not the same class, or
|
|
348
|
+
* one a rename of the other, is not the same Symbol however well the rest agrees, and no
|
|
349
|
+
* weight small enough to leave the name axis meaning something can outvote a matching member
|
|
350
|
+
* name and signature.
|
|
351
|
+
*
|
|
352
|
+
* The composite score, over the pairs that clear it:
|
|
353
|
+
* 0.5 * memberSimilarity + 0.3 * signatureSimilarity + 0.2 * OWNER_AXIS_SATISFIED
|
|
194
354
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* - default → 0.85
|
|
355
|
+
* `memberSimilarity` reads the last segment, not the whole qualified name: the gate has
|
|
356
|
+
* already settled the owner, and reading it again on the name axis charges for it twice —
|
|
357
|
+
* which is what kept a renamed class from ever pairing (§3.4.6).
|
|
199
358
|
*
|
|
200
|
-
* §3.4.3
|
|
201
|
-
*
|
|
202
|
-
*
|
|
359
|
+
* §3.4.3 threshold table, applied per head by the token count of its last name segment:
|
|
360
|
+
* - 1 token → 1.0 (an exact match on both remaining axes)
|
|
361
|
+
* - 2 tokens → 0.95 (strict, avoids `getUser` vs `getUsers`)
|
|
362
|
+
* - default → 0.85
|
|
363
|
+
*
|
|
364
|
+
* §3.4.3 also names two Symbols the stage does not read at all, because for them the score is
|
|
365
|
+
* high on evidence it does not have rather than on a resemblance: one whose signature is
|
|
366
|
+
* null, since `signatureSimilarity(null, null)` is 1.0 and the bucket key restricts it to
|
|
367
|
+
* candidates that are equally signature-less; and one whose qualified name carries a single
|
|
368
|
+
* distinct token, since that is the whole of what its identity amounts to. Neither can be
|
|
369
|
+
* expressed as a threshold — the second wants a bar above the top of the scale.
|
|
370
|
+
*
|
|
371
|
+
* Every pairing in a bucket that clears its head’s threshold becomes a candidate, and the
|
|
372
|
+
* candidates are settled in score order rather than one head at a time — see
|
|
373
|
+
* `acceptInScoreOrder`. That doubles the pairings scored, from a bucket that shrank as heads
|
|
374
|
+
* consumed it to the full bucket every time; `createNameScorer` more than pays for it by
|
|
375
|
+
* tokenising each distinct name once per pass instead of once per comparison.
|
|
203
376
|
*/
|
|
204
377
|
export function matchStageNameSignature(
|
|
205
378
|
remainingBase: readonly IRSymbol[],
|
|
@@ -209,63 +382,116 @@ export function matchStageNameSignature(
|
|
|
209
382
|
remainingBase: IRSymbol[]
|
|
210
383
|
remainingHead: IRSymbol[]
|
|
211
384
|
} {
|
|
212
|
-
|
|
213
|
-
const buckets = new Map<BucketKey, IRSymbol[]>()
|
|
385
|
+
const buckets = new Map<string, Bucket>()
|
|
214
386
|
for (const b of remainingBase) {
|
|
215
387
|
if (b.dropped) continue
|
|
388
|
+
// §3.4.3 — see `saysEnoughToPair`. Read on the base as well as the head: the property
|
|
389
|
+
// belongs to a pairing, and either end being short is enough to make the score unearned.
|
|
390
|
+
if (!saysEnoughToPair(b.name)) continue
|
|
216
391
|
const key = bucketKey(b)
|
|
217
|
-
const bucket = buckets.get(key) ?? []
|
|
218
|
-
bucket.
|
|
392
|
+
const bucket: Bucket = buckets.get(key) ?? { members: [], byToken: new Map() }
|
|
393
|
+
const position = bucket.members.length
|
|
394
|
+
bucket.members.push(b)
|
|
395
|
+
for (const token of memberTokens(b.name)) {
|
|
396
|
+
const sharing = bucket.byToken.get(token) ?? []
|
|
397
|
+
sharing.push(position)
|
|
398
|
+
bucket.byToken.set(token, sharing)
|
|
399
|
+
}
|
|
219
400
|
buckets.set(key, bucket)
|
|
220
401
|
}
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
const
|
|
402
|
+
const visitsOf = new Map<Bucket, Int32Array>()
|
|
403
|
+
for (const bucket of buckets.values()) visitsOf.set(bucket, new Int32Array(bucket.members.length))
|
|
404
|
+
const scorer = createNameScorer()
|
|
405
|
+
const candidates: ScoredPair[] = []
|
|
406
|
+
let visitCount = 0
|
|
224
407
|
for (const h of remainingHead) {
|
|
225
|
-
if (h.dropped)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
237
|
-
|
|
408
|
+
if (h.dropped) continue
|
|
409
|
+
// §3.4.3 tail — `signatureSimilarity(null, null)` is 1.0, so a signature-less head would
|
|
410
|
+
// score every candidate high on that axis. The bucket key already partitions by
|
|
411
|
+
// signature nullness, so a signature-less head only ever sees signature-less candidates:
|
|
412
|
+
// there is nothing here it could legitimately pair with.
|
|
413
|
+
if (h.signature === null || h.signature === undefined) continue
|
|
414
|
+
// §3.4.3 — a name of one distinct token scores 1.0 against any other of the same
|
|
415
|
+
// signature, which is the top of the scale and so above no threshold. See
|
|
416
|
+
// `saysEnoughToPair`.
|
|
417
|
+
if (!saysEnoughToPair(h.name)) continue
|
|
418
|
+
const bucket = buckets.get(bucketKey(h))
|
|
419
|
+
if (bucket === undefined) continue
|
|
420
|
+
const threshold = thresholdFor(h.name)
|
|
421
|
+
// §3.4.0 — the least a member name can score and still leave the threshold reachable,
|
|
422
|
+
// granting the other two axes in full.
|
|
423
|
+
const memberFloor = lowestUsefulMember(threshold)
|
|
424
|
+
// A base is reached once per token it shares, so a head whose postings add up to at least
|
|
425
|
+
// the whole bucket is not being narrowed by the index — only charged for the overlap.
|
|
426
|
+
// Every Symbol named `handleRequest` puts the entire bucket under both of its tokens.
|
|
427
|
+
// Walking the members straight through covers that without the de-duplication, and keeps
|
|
428
|
+
// the index from costing more than not having one. A bound rather than a promise of the
|
|
429
|
+
// cheaper branch: `reach` double-counts, so a head can trip it while covering much less.
|
|
430
|
+
const tokens = memberTokens(h.name)
|
|
431
|
+
let reach = 0
|
|
432
|
+
for (const token of tokens) reach += bucket.byToken.get(token)?.length ?? 0
|
|
433
|
+
if (reach >= bucket.members.length) {
|
|
434
|
+
for (const b of bucket.members) {
|
|
435
|
+
collectCandidate(b, h, scorer, memberFloor, threshold, candidates)
|
|
436
|
+
}
|
|
238
437
|
continue
|
|
239
438
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
matched.push({ base: best.symbol, head: h, rationale: "name-signature" })
|
|
251
|
-
usedBaseIds.add(best.symbol.id)
|
|
252
|
-
buckets.set(
|
|
253
|
-
key,
|
|
254
|
-
bucket.filter((c) => c.id !== best?.symbol.id),
|
|
255
|
-
)
|
|
256
|
-
} else {
|
|
257
|
-
remainingHeadOut.push(h)
|
|
439
|
+
const visited = visitsOf.get(bucket)
|
|
440
|
+
if (visited === undefined) continue
|
|
441
|
+
visitCount++
|
|
442
|
+
for (const token of tokens) {
|
|
443
|
+
for (const position of bucket.byToken.get(token) ?? []) {
|
|
444
|
+
if (visited[position] === visitCount) continue
|
|
445
|
+
visited[position] = visitCount
|
|
446
|
+
const b = bucket.members[position]
|
|
447
|
+
if (b !== undefined) collectCandidate(b, h, scorer, memberFloor, threshold, candidates)
|
|
448
|
+
}
|
|
258
449
|
}
|
|
259
450
|
}
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
451
|
+
const accepted = acceptInScoreOrder(candidates)
|
|
452
|
+
return {
|
|
453
|
+
matched: accepted.pairs.map(({ base, head }) => ({
|
|
454
|
+
base,
|
|
455
|
+
head,
|
|
456
|
+
rationale: "name-signature" as const,
|
|
457
|
+
})),
|
|
458
|
+
remainingBase: unclaimed(remainingBase, accepted.baseIds),
|
|
459
|
+
remainingHead: unclaimed(remainingHead, accepted.headIds),
|
|
267
460
|
}
|
|
268
|
-
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* §3.4 — score one candidate pairing and keep it if it clears the head's threshold.
|
|
465
|
+
*
|
|
466
|
+
* A plain function rather than a closure over the head loop: both branches of the walk need
|
|
467
|
+
* it, and a closure built per head is the cross-product moved into the allocator — measurably
|
|
468
|
+
* so, at around 1.6x on a bucket the index cannot narrow.
|
|
469
|
+
*
|
|
470
|
+
* §3.4.3's floor is read before §3.4.6's gate and is the only early exit here. It is one
|
|
471
|
+
* Jaccard over token sets the pass already holds, where the gate splits both owners into
|
|
472
|
+
* segments, tokenises each and runs an augmenting-path matching. The gate short-circuits
|
|
473
|
+
* internally on identical owners and on first segments that cannot correspond, so there is
|
|
474
|
+
* nothing left worth hoisting out of it.
|
|
475
|
+
*/
|
|
476
|
+
function collectCandidate(
|
|
477
|
+
base: IRSymbol,
|
|
478
|
+
head: IRSymbol,
|
|
479
|
+
scorer: NameScorer,
|
|
480
|
+
memberFloor: number,
|
|
481
|
+
threshold: number,
|
|
482
|
+
candidates: ScoredPair[],
|
|
483
|
+
): void {
|
|
484
|
+
const member = scorer.member(base.name, head.name)
|
|
485
|
+
if (member < memberFloor) return
|
|
486
|
+
if (!scorer.ownersCompatible(base.name, head.name)) return
|
|
487
|
+
const score =
|
|
488
|
+
0.5 * member +
|
|
489
|
+
0.3 * signatureSimilarity(base.signature ?? null, head.signature ?? null) +
|
|
490
|
+
0.2 * OWNER_AXIS_SATISFIED
|
|
491
|
+
// Filtering here rather than after the sweep keeps the candidate list to the pairings that
|
|
492
|
+
// could actually be accepted; the threshold belongs to the head, so a pair below it is never
|
|
493
|
+
// acceptable however the rest of the group resolves.
|
|
494
|
+
if (score >= threshold) candidates.push({ base, head, score })
|
|
269
495
|
}
|
|
270
496
|
|
|
271
497
|
function bucketKey(s: IRSymbol): string {
|
|
@@ -273,21 +499,138 @@ function bucketKey(s: IRSymbol): string {
|
|
|
273
499
|
return `${s.kind}::${sig}`
|
|
274
500
|
}
|
|
275
501
|
|
|
502
|
+
/**
|
|
503
|
+
* §3.4.0 — the least `memberSimilarity` that leaves `threshold` reachable.
|
|
504
|
+
*
|
|
505
|
+
* `0.5 * member + 0.3 * signature + 0.2 * 1` must reach the threshold, and the signature axis
|
|
506
|
+
* is worth at most 0.3, so `member >= 2 * (threshold - 0.5)`. Refusing below this is exact
|
|
507
|
+
* rather than approximate: nothing the other two axes can do rescues a pair under it.
|
|
508
|
+
*
|
|
509
|
+
* Reading it before §3.4.6's gate is what keeps stage 4 off the cross-product. The gate splits
|
|
510
|
+
* and tokenises two owners; this is one Jaccard over token sets the pass has already built,
|
|
511
|
+
* and it refuses most of what a shared token admits.
|
|
512
|
+
*/
|
|
513
|
+
function lowestUsefulMember(threshold: number): number {
|
|
514
|
+
return 2 * (threshold - 0.5)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* One `(kind, signatureNullness)` bucket: its base Symbols, and an index from each token a
|
|
519
|
+
* member name carries to the positions holding it.
|
|
520
|
+
*
|
|
521
|
+
* The de-duplication stamp lives beside the bucket rather than in it, in `visitsOf`, and is
|
|
522
|
+
* sized once the bucket is complete — a half-built bucket is then not representable, and the
|
|
523
|
+
* stamp cannot be indexed out of range. It is per bucket rather than per head because a head
|
|
524
|
+
* reaching every base would otherwise allocate a set the size of the bucket once per head,
|
|
525
|
+
* which is the cross-product the index exists to avoid moved into the allocator.
|
|
526
|
+
*/
|
|
527
|
+
interface Bucket {
|
|
528
|
+
members: IRSymbol[]
|
|
529
|
+
byToken: Map<string, number[]>
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* §3.4.0 — the token that stands in for a member name carrying none.
|
|
534
|
+
*
|
|
535
|
+
* `Foo.Bar.` has an empty last segment and two tokens in its qualified name, so it is
|
|
536
|
+
* admissible, and two such Symbols score 1.0 on an axis comparing two empty sets. They have
|
|
537
|
+
* no token to be indexed under and cannot pair with anything that has one, so they share a
|
|
538
|
+
* key of their own. A real token cannot collide with it: `tokenizeName` never emits a space.
|
|
539
|
+
*/
|
|
540
|
+
const NO_MEMBER_TOKENS = " none"
|
|
541
|
+
|
|
542
|
+
function memberTokens(qname: string): readonly string[] {
|
|
543
|
+
const tokens = tokenizeName(lastSegment(qname))
|
|
544
|
+
return tokens.length === 0 ? [NO_MEMBER_TOKENS] : tokens
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* §3.4.3 — the composite score a pair must reach, by how many tokens the head's last name
|
|
549
|
+
* segment has. A one-token segment still asks for the whole scale, which a pair reaches on an
|
|
550
|
+
* identical member name and signature past §3.4.6's gate: `0.5 + 0.3 + 0.2`, exactly 1 in
|
|
551
|
+
* IEEE 754. The owner need not be identical, only compatible — `UserRepo.get` and
|
|
552
|
+
* `UserRepos.get` reach it.
|
|
553
|
+
*/
|
|
554
|
+
const EXACT_MATCH_ONLY = 1
|
|
555
|
+
const TWO_TOKEN_THRESHOLD = 0.95
|
|
556
|
+
const DEFAULT_THRESHOLD = 0.85
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* §3.4.6 — what the owner axis is worth to a pair that clears its gate, which is every pair
|
|
560
|
+
* that reaches the score. Written as a value rather than folded into the weights so the
|
|
561
|
+
* composite keeps the 0.5/0.3/0.2 shape §3.4.3's rows are calibrated against: dropping the
|
|
562
|
+
* term and renormalising would move every threshold without changing what any of them means.
|
|
563
|
+
*/
|
|
564
|
+
const OWNER_AXIS_SATISFIED = 1
|
|
565
|
+
|
|
276
566
|
function thresholdFor(qname: string): number {
|
|
277
567
|
const tokens = tokenizeName(lastSegment(qname)).length
|
|
278
|
-
if (tokens <= 1) return
|
|
279
|
-
if (tokens === 2) return
|
|
280
|
-
return
|
|
568
|
+
if (tokens <= 1) return EXACT_MATCH_ONLY
|
|
569
|
+
if (tokens === 2) return TWO_TOKEN_THRESHOLD
|
|
570
|
+
return DEFAULT_THRESHOLD
|
|
281
571
|
}
|
|
282
572
|
|
|
283
573
|
/**
|
|
284
|
-
* §3.4.
|
|
285
|
-
*
|
|
286
|
-
*
|
|
574
|
+
* §3.4.3 — whether a Symbol's qualified name says enough for stage 4 to read it at all.
|
|
575
|
+
*
|
|
576
|
+
* The score is built from three things the name supplies: its tokens, its owner's tokens,
|
|
577
|
+
* and a signature. A name of one distinct token supplies one word and an owner that either
|
|
578
|
+
* repeats it or is empty, and `main(x: string): void` against another `main(x: string): void`
|
|
579
|
+
* scores the full 1.0 on that — two unrelated CLI entry points reported as one move. The
|
|
580
|
+
* table's first row was written to refuse this and could not: 1.0 is the top of the scale,
|
|
581
|
+
* so no threshold sits above it. Being unpairable is a property of the name, not a score it
|
|
582
|
+
* failed to reach, so it is decided here rather than by the comparison.
|
|
583
|
+
*
|
|
584
|
+
* Counted over the whole qualified name, because that is what the score reads.
|
|
585
|
+
* `UserRepo.get` supplies three tokens and pairs, though its last segment is one — the
|
|
586
|
+
* measure `thresholdFor` uses, and the wrong one for this question. Tokens are deduped, so
|
|
587
|
+
* `Main.main` supplies one and does not.
|
|
588
|
+
*
|
|
589
|
+
* Read off both sides, because the property belongs to a pairing. It was once read off the
|
|
590
|
+
* head alone, on the arithmetic that a short name anywhere capped the score at 0.75 — true
|
|
591
|
+
* while the name axis read the whole qualified name, and false since §3.4.6 became a gate and
|
|
592
|
+
* the axis moved to the last segment. `Main.main` is one deduped token and clears the gate
|
|
593
|
+
* against `Mains.main`, whose member name is identical, so the pair reaches the top of the
|
|
594
|
+
* scale from a name saying one word.
|
|
287
595
|
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
596
|
+
* The count stands in for how much the name says, and `tokenizeName` splits on ASCII case
|
|
597
|
+
* boundaries, so a name written in a script that has none is one token however long it is:
|
|
598
|
+
* `ユーザー情報を取得する` is refused here on the same footing as `main`. For that name the
|
|
599
|
+
* proxy is wrong — two unrelated Symbols do not carry it by coincidence the way two carry
|
|
600
|
+
* `main` — and the cost is a stage-4 move this stage will not find. Fixing it means measuring
|
|
601
|
+
* the name by something other than a bare token count, which is §3.4.1's to change.
|
|
602
|
+
*/
|
|
603
|
+
function saysEnoughToPair(qname: string): boolean {
|
|
604
|
+
return tokenizeName(qname).length > 1
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* §3.4.5 — dropped-only weak matcher. For dropped Symbols the fingerprint is zeroed, so the
|
|
609
|
+
* only signals left are the trailing segment of the qualified name and the file basename.
|
|
610
|
+
* Either one alone is enough to pair, which is deliberately lax: §3.4.5 accepts a
|
|
611
|
+
* false-positive risk because dropped Symbols sit outside the IR's main review surface.
|
|
612
|
+
*
|
|
613
|
+
* What it does not accept is a signal that identifies nothing. A basename hit on `index.ts`
|
|
614
|
+
* — the most common filename in a TypeScript monorepo — paired every dropped Symbol of one
|
|
615
|
+
* kind under one with every other, at a score they all tied on, and the results landed in
|
|
616
|
+
* `summary.moved`, which `--fail-on moved` gates on. So a half counts only when exactly one
|
|
617
|
+
* dropped base and one dropped head carry that key: such a key names a pair, where a key
|
|
618
|
+
* several Symbols carry names a group, and a group is not a pairing.
|
|
619
|
+
*
|
|
620
|
+
* That makes the candidates the identifying keys themselves — at most one pairing each,
|
|
621
|
+
* two axes, so at most `2 × min(base, head)` of them rather than the cross-product a shared
|
|
622
|
+
* basename used to produce.
|
|
623
|
+
*
|
|
624
|
+
* "Exactly one" is counted over the Symbols this stage is handed, not over every dropped
|
|
625
|
+
* Symbol in the Document — stages 1 and 2 have already taken theirs, and a key they emptied
|
|
626
|
+
* out identifies again. That is the intended reading, since the question is which leftover a
|
|
627
|
+
* key picks out, but it is also how a shared `index.ts` comes back: three dropped classes
|
|
628
|
+
* under one, two of them matched by id, and the basename identifies the two that are left.
|
|
629
|
+
*
|
|
630
|
+
* It also leaves nothing for §3.4.5's 0.5-per-half scale to rank, so §3.8's sweep is the
|
|
631
|
+
* wrong instrument: it settles conflicts by score, and with none to settle by it would drop
|
|
632
|
+
* one identified pairing for another over nothing but the id it sorts under. `bestPairing`
|
|
633
|
+
* takes a maximum matching instead.
|
|
291
634
|
*/
|
|
292
635
|
export function matchStageDroppedWeak(
|
|
293
636
|
remainingBase: readonly IRSymbol[],
|
|
@@ -297,39 +640,155 @@ export function matchStageDroppedWeak(
|
|
|
297
640
|
remainingBase: IRSymbol[]
|
|
298
641
|
remainingHead: IRSymbol[]
|
|
299
642
|
} {
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (b.kind !== h.kind) continue
|
|
311
|
-
if (usedBaseIds.has(b.id)) continue
|
|
312
|
-
const nameHit = lastSegment(b.name) === lastSegment(h.name) ? 1 : 0
|
|
313
|
-
const fileHit = basename(b.source.file) === basename(h.source.file) ? 1 : 0
|
|
314
|
-
const score = 0.5 * nameHit + 0.5 * fileHit
|
|
315
|
-
if (best === null || score > best.score) best = { symbol: b, score }
|
|
316
|
-
}
|
|
317
|
-
if (best !== null && best.score >= 0.5) {
|
|
318
|
-
matched.push({ base: best.symbol, head: h, rationale: "dropped-weak-match" })
|
|
319
|
-
usedBaseIds.add(best.symbol.id)
|
|
320
|
-
} else {
|
|
321
|
-
carryHead.push(h)
|
|
322
|
-
}
|
|
643
|
+
const bases = remainingBase.filter((s) => s.dropped)
|
|
644
|
+
const heads = remainingHead.filter((s) => s.dropped)
|
|
645
|
+
|
|
646
|
+
// Both halves have the same standing, so the two axes are collected rather than consulted
|
|
647
|
+
// in an order. Both naming the same pairing needs no special handling: each key is sole on
|
|
648
|
+
// both sides, so neither Symbol carries another pairing and the repeat is a component of
|
|
649
|
+
// its own, which `bestPairing` takes once.
|
|
650
|
+
const identified: WeakEdge[] = []
|
|
651
|
+
for (const keyOf of [nameKey, fileKey]) {
|
|
652
|
+
identified.push(...pairsIdentifiedBy(bases, heads, keyOf))
|
|
323
653
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
...droppedBase.filter((b) => !usedBaseIds.has(b.id)),
|
|
327
|
-
]
|
|
654
|
+
|
|
655
|
+
const matched = bestPairing(identified)
|
|
328
656
|
return {
|
|
329
|
-
matched,
|
|
330
|
-
|
|
331
|
-
|
|
657
|
+
matched: matched.map(({ base, head }) => ({
|
|
658
|
+
base,
|
|
659
|
+
head,
|
|
660
|
+
rationale: "dropped-weak-match" as const,
|
|
661
|
+
})),
|
|
662
|
+
remainingBase: unclaimed(remainingBase, new Set(matched.map((edge) => edge.base.id))),
|
|
663
|
+
remainingHead: unclaimed(remainingHead, new Set(matched.map((edge) => edge.head.id))),
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** A pairing that one of §3.4.5's two keys identifies. */
|
|
668
|
+
interface WeakEdge {
|
|
669
|
+
base: IRSymbol
|
|
670
|
+
head: IRSymbol
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* As many of the identified pairings as can hold at once.
|
|
675
|
+
*
|
|
676
|
+
* Each axis identifies a Symbol at most once, so a Symbol carries at most two pairings and
|
|
677
|
+
* the whole set is the union of two matchings — which is a disjoint union of simple paths and
|
|
678
|
+
* even cycles. Alternate pairings along a component are therefore a maximum matching of it,
|
|
679
|
+
* and walking each component from a fixed end makes the choice among the maximum matchings
|
|
680
|
+
* canonical rather than a property of the input order.
|
|
681
|
+
*
|
|
682
|
+
* The alternative would be §3.8's sweep, and it is the wrong instrument once §3.4.5's score
|
|
683
|
+
* is gone: over `Shared.ts#Alpha`, `Other.ts#Beta` against `Other.ts#Alpha`,
|
|
684
|
+
* `Shared.ts#Gamma`, all three pairings are identified and it takes one where two hold.
|
|
685
|
+
*/
|
|
686
|
+
function bestPairing(edges: readonly WeakEdge[]): WeakEdge[] {
|
|
687
|
+
const ordered = [...edges].sort(byEndpoints)
|
|
688
|
+
const atBase = new Map<SymbolId, number[]>()
|
|
689
|
+
const atHead = new Map<SymbolId, number[]>()
|
|
690
|
+
for (const [index, edge] of ordered.entries()) {
|
|
691
|
+
appendTo(atBase, edge.base.id, index)
|
|
692
|
+
appendTo(atHead, edge.head.id, index)
|
|
693
|
+
}
|
|
694
|
+
const adjacent = (index: number): number[] => {
|
|
695
|
+
const edge = ordered[index]
|
|
696
|
+
if (edge === undefined) return []
|
|
697
|
+
const sharing = [...(atBase.get(edge.base.id) ?? []), ...(atHead.get(edge.head.id) ?? [])]
|
|
698
|
+
return sharing.filter((other) => other !== index)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const walked = ordered.map(() => false)
|
|
702
|
+
const matching: WeakEdge[] = []
|
|
703
|
+
/** Follow a component from `start`, taking every other pairing along it. */
|
|
704
|
+
const walkFrom = (start: number): void => {
|
|
705
|
+
let step = start
|
|
706
|
+
let take = true
|
|
707
|
+
while (!walked[step]) {
|
|
708
|
+
walked[step] = true
|
|
709
|
+
const edge = ordered[step]
|
|
710
|
+
if (take && edge !== undefined) matching.push(edge)
|
|
711
|
+
take = !take
|
|
712
|
+
const next = adjacent(step).find((other) => walked[other] === false)
|
|
713
|
+
if (next === undefined) return
|
|
714
|
+
step = next
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Paths first, from an end — a component walked from the middle would not alternate to a
|
|
719
|
+
// maximum. What is unwalked afterwards is on a cycle, where every pairing is an equally
|
|
720
|
+
// good place to start and the lowest-ordered one is the canonical choice.
|
|
721
|
+
for (const [index] of ordered.entries()) {
|
|
722
|
+
if (!walked[index] && adjacent(index).length <= 1) walkFrom(index)
|
|
332
723
|
}
|
|
724
|
+
for (const [index] of ordered.entries()) {
|
|
725
|
+
if (!walked[index]) walkFrom(index)
|
|
726
|
+
}
|
|
727
|
+
return matching
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function byEndpoints(a: WeakEdge, b: WeakEdge): number {
|
|
731
|
+
if (a.base.id !== b.base.id) return a.base.id < b.base.id ? -1 : 1
|
|
732
|
+
return a.head.id < b.head.id ? -1 : a.head.id > b.head.id ? 1 : 0
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function appendTo(table: Map<SymbolId, number[]>, key: SymbolId, index: number): void {
|
|
736
|
+
const bucket = table.get(key)
|
|
737
|
+
if (bucket === undefined) table.set(key, [index])
|
|
738
|
+
else bucket.push(index)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* The pairings a key picks out on its own: those whose key exactly one dropped base and one
|
|
743
|
+
* dropped head carry. A key held by two Symbols on either side is discarded rather than
|
|
744
|
+
* resolved — with the fingerprint zeroed there is nothing left to tell the members apart, and
|
|
745
|
+
* guessing between them is the false pairing this rule exists to refuse.
|
|
746
|
+
*/
|
|
747
|
+
function pairsIdentifiedBy(
|
|
748
|
+
bases: readonly IRSymbol[],
|
|
749
|
+
heads: readonly IRSymbol[],
|
|
750
|
+
keyOf: (symbol: IRSymbol) => string,
|
|
751
|
+
): WeakEdge[] {
|
|
752
|
+
const soleBase = soleCarriers(bases, keyOf)
|
|
753
|
+
const soleHead = soleCarriers(heads, keyOf)
|
|
754
|
+
const pairs: WeakEdge[] = []
|
|
755
|
+
for (const [key, base] of soleBase) {
|
|
756
|
+
const head = soleHead.get(key)
|
|
757
|
+
if (head !== undefined) pairs.push({ base, head })
|
|
758
|
+
}
|
|
759
|
+
return pairs
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Keys carried by exactly one of `symbols`, mapped to it. */
|
|
763
|
+
function soleCarriers(
|
|
764
|
+
symbols: readonly IRSymbol[],
|
|
765
|
+
keyOf: (symbol: IRSymbol) => string,
|
|
766
|
+
): Map<string, IRSymbol> {
|
|
767
|
+
const sole = new Map<string, IRSymbol>()
|
|
768
|
+
const shared = new Set<string>()
|
|
769
|
+
for (const symbol of symbols) {
|
|
770
|
+
const key = keyOf(symbol)
|
|
771
|
+
if (shared.has(key)) continue
|
|
772
|
+
if (sole.has(key)) {
|
|
773
|
+
sole.delete(key)
|
|
774
|
+
shared.add(key)
|
|
775
|
+
continue
|
|
776
|
+
}
|
|
777
|
+
sole.set(key, symbol)
|
|
778
|
+
}
|
|
779
|
+
return sole
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The two halves §3.4.5 scores. `kind` leads because it gates a pair before either half is
|
|
784
|
+
* read, and `/` separates because no kind, qualified-name segment or basename contains one.
|
|
785
|
+
*/
|
|
786
|
+
function nameKey(symbol: IRSymbol): string {
|
|
787
|
+
return `${symbol.kind}/${lastSegment(symbol.name)}`
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function fileKey(symbol: IRSymbol): string {
|
|
791
|
+
return `${symbol.kind}/${basename(symbol.source.file)}`
|
|
333
792
|
}
|
|
334
793
|
|
|
335
794
|
function basename(path: string): string {
|