@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/match.ts DELETED
@@ -1,797 +0,0 @@
1
- import { trySymbolId } from "@aburi/core"
2
- import type { Symbol as IRSymbol, MatchRationale, SymbolId } from "@aburi/types"
3
- import { signatureSimilarity } from "./signature"
4
- import { createNameScorer, lastSegment, type NameScorer, tokenizeName } from "./similarity"
5
-
6
- /**
7
- * A concluded pairing of one base Symbol with one head Symbol, along with the
8
- * `MatchRationale` that placed them in the same pair. Stage 4 uses this shape both to
9
- * carry results out and to feed the delta / status classifier downstream.
10
- */
11
- export interface SymbolPair {
12
- base: IRSymbol
13
- head: IRSymbol
14
- rationale: MatchRationale
15
- }
16
-
17
- /** Optional map returned by git for stage 2 (`old path` → `new path`). */
18
- export type GitRenameMap = ReadonlyMap<string, string>
19
-
20
- /** Fingerprint for the 12-hex-char "everything zero" (i.e. dropped) canonical hash. */
21
- const ZERO_LOGIC_FP = "000000000000"
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
-
96
- /**
97
- * §3.1 — segments Symbols with identical `id` into paired matches. Runs first because it
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.
106
- */
107
- export function matchStageId(
108
- base: readonly IRSymbol[],
109
- head: readonly IRSymbol[],
110
- ): {
111
- matched: SymbolPair[]
112
- remainingBase: IRSymbol[]
113
- remainingHead: IRSymbol[]
114
- } {
115
- const headById = new Map<SymbolId, IRSymbol>()
116
- for (const s of head) headById.set(s.id, s)
117
- const matched: SymbolPair[] = []
118
- const remainingBase: IRSymbol[] = []
119
- const usedHead = new Set<SymbolId>()
120
- for (const b of base) {
121
- const h = headById.get(b.id)
122
- if (h !== undefined) {
123
- matched.push({ base: b, head: h, rationale: "id-match" })
124
- usedHead.add(b.id)
125
- } else {
126
- remainingBase.push(b)
127
- }
128
- }
129
- const remainingHead = head.filter((h) => !usedHead.has(h.id))
130
- return { matched, remainingBase, remainingHead }
131
- }
132
-
133
- /**
134
- * §3.2 — git rename detection. When a rename map is available (typical of ref-driven
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.
138
- *
139
- * The rewriter only touches the `file` portion of the id — the `<language>:` prefix and
140
- * the trailing `#<qualified-name>` segment stay unchanged so a moved file with the same
141
- * qualified name is picked up correctly. When a Symbol's file is not in the rename map,
142
- * it is left for later stages.
143
- */
144
- export function matchStageGitRename(
145
- remainingBase: readonly IRSymbol[],
146
- remainingHead: readonly IRSymbol[],
147
- renameMap: GitRenameMap | null,
148
- ): {
149
- matched: SymbolPair[]
150
- remainingBase: IRSymbol[]
151
- remainingHead: IRSymbol[]
152
- } {
153
- if (renameMap === null || renameMap.size === 0) {
154
- return { matched: [], remainingBase: [...remainingBase], remainingHead: [...remainingHead] }
155
- }
156
- const headById = new Map<SymbolId, IRSymbol>()
157
- for (const h of remainingHead) headById.set(h.id, h)
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[]] }>()
163
- for (const b of remainingBase) {
164
- const newPath = renameMap.get(b.source.file)
165
- if (newPath === undefined) continue
166
- const expectedId = rewriteIdFile(b.id, b.source.file, newPath)
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
196
- }
197
- return lowest
198
- }
199
-
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 {
211
- const colon = id.indexOf(":")
212
- const hash = id.indexOf("#")
213
- if (colon < 0 || hash < 0 || hash < colon) return id
214
- const filePart = id.slice(colon + 1, hash)
215
- if (filePart !== oldPath) return id
216
- return trySymbolId({
217
- language: id.slice(0, colon),
218
- file: newPath,
219
- qualifiedName: id.slice(hash + 1),
220
- })
221
- }
222
-
223
- /**
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
228
- *
229
- * Dropped symbols are excluded (§3.3 tail) — their logic fingerprint is the sentinel
230
- * `"000000000000"` and would collide with every other dropped Symbol in the workspace.
231
- * They flow to the stage-4.5 weak matcher instead.
232
- */
233
- export function matchStageLogicFingerprint(
234
- remainingBase: readonly IRSymbol[],
235
- remainingHead: readonly IRSymbol[],
236
- ): {
237
- matched: SymbolPair[]
238
- remainingBase: IRSymbol[]
239
- remainingHead: IRSymbol[]
240
- } {
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),
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]
293
- const matched: SymbolPair[] = []
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
301
- }
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
- }
308
- }
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" })
313
- }
314
- freeBase = unclaimed(freeBase, accepted.baseIds)
315
- freeHead = unclaimed(freeHead, accepted.headIds)
316
- }
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
336
- }
337
- if (score === best.score && head.id < best.head.id) best = { head, score }
338
- }
339
- return best?.head
340
- }
341
-
342
- /**
343
- * §3.4 — name + signature similarity with `(kind, signatureNullness)` bucket pre-filter.
344
- * `signatureNullness` distinguishes symbols with no signature (`interface`, `type`,
345
- * `class`) from callable ones so that a class body is never paired against a function.
346
- *
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
354
- *
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).
358
- *
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.
376
- */
377
- export function matchStageNameSignature(
378
- remainingBase: readonly IRSymbol[],
379
- remainingHead: readonly IRSymbol[],
380
- ): {
381
- matched: SymbolPair[]
382
- remainingBase: IRSymbol[]
383
- remainingHead: IRSymbol[]
384
- } {
385
- const buckets = new Map<string, Bucket>()
386
- for (const b of remainingBase) {
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
391
- const key = bucketKey(b)
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
- }
400
- buckets.set(key, bucket)
401
- }
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
407
- for (const h of remainingHead) {
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
- }
437
- continue
438
- }
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
- }
449
- }
450
- }
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),
460
- }
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 })
495
- }
496
-
497
- function bucketKey(s: IRSymbol): string {
498
- const sig = s.signature === null || s.signature === undefined ? "no-sig" : "has-sig"
499
- return `${s.kind}::${sig}`
500
- }
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
-
566
- function thresholdFor(qname: string): number {
567
- const tokens = tokenizeName(lastSegment(qname)).length
568
- if (tokens <= 1) return EXACT_MATCH_ONLY
569
- if (tokens === 2) return TWO_TOKEN_THRESHOLD
570
- return DEFAULT_THRESHOLD
571
- }
572
-
573
- /**
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.
595
- *
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.
634
- */
635
- export function matchStageDroppedWeak(
636
- remainingBase: readonly IRSymbol[],
637
- remainingHead: readonly IRSymbol[],
638
- ): {
639
- matched: SymbolPair[]
640
- remainingBase: IRSymbol[]
641
- remainingHead: IRSymbol[]
642
- } {
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))
653
- }
654
-
655
- const matched = bestPairing(identified)
656
- return {
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)
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)}`
792
- }
793
-
794
- function basename(path: string): string {
795
- const slash = path.lastIndexOf("/")
796
- return slash >= 0 ? path.slice(slash + 1) : path
797
- }