@aburi/diff 0.1.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/README.md +62 -0
- package/dist/index.d.mts +277 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1041 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +50 -0
- package/src/components.ts +105 -0
- package/src/delta.ts +301 -0
- package/src/diff.ts +237 -0
- package/src/errors.ts +35 -0
- package/src/index.ts +45 -0
- package/src/match.ts +338 -0
- package/src/signature.ts +53 -0
- package/src/similarity.ts +134 -0
- package/src/status.ts +36 -0
package/src/diff.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { type SerializeOptions, serializeCanonical } from "@aburi/core"
|
|
2
|
+
import type { DiffResult, IR, IRRef, Summary, SymbolChange } from "@aburi/types"
|
|
3
|
+
import { diffComponents, diffDependencies } from "./components"
|
|
4
|
+
import { computeSymbolDelta, type DeltaOptions } from "./delta"
|
|
5
|
+
import { DiffError } from "./errors"
|
|
6
|
+
import {
|
|
7
|
+
type GitRenameMap,
|
|
8
|
+
matchStageDroppedWeak,
|
|
9
|
+
matchStageGitRename,
|
|
10
|
+
matchStageId,
|
|
11
|
+
matchStageLogicFingerprint,
|
|
12
|
+
matchStageNameSignature,
|
|
13
|
+
type SymbolPair,
|
|
14
|
+
} from "./match"
|
|
15
|
+
import { classifyStatus, dropDirection } from "./status"
|
|
16
|
+
|
|
17
|
+
const DIFF_SCHEMA = "https://aburi.dev/schema/aburi.diff.v1.json"
|
|
18
|
+
|
|
19
|
+
export interface DiffInput {
|
|
20
|
+
baseIR: IR
|
|
21
|
+
headIR: IR
|
|
22
|
+
/** IR reference metadata (git ref / file path) for provenance. */
|
|
23
|
+
base: IRRef
|
|
24
|
+
head: IRRef
|
|
25
|
+
/** Generator record for the diff output. Defaults to `{name: "aburi", version: "0.0.0"}`. */
|
|
26
|
+
generator?: { name: string; version: string }
|
|
27
|
+
/** Optional stage-2 rename table. When null/undefined stage 2 is skipped. */
|
|
28
|
+
gitRenames?: GitRenameMap | null
|
|
29
|
+
/** Passed through to computeSymbolDelta (§5.2.1 line fuzz). */
|
|
30
|
+
delta?: DeltaOptions
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_GENERATOR = { name: "aburi", version: "0.0.0" }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Top-level entry: run the 5-stage matcher, classify each pair, produce array deltas,
|
|
37
|
+
* fold in Component / Dependency diffs, and assemble the `aburi.diff.v1` JSON projection.
|
|
38
|
+
* The function is pure; write-to-disk is delegated to `writeCanonicalDiff` so callers can
|
|
39
|
+
* pipe the result through additional steps (Markdown projection, `--fail-on` gate) before
|
|
40
|
+
* serialisation.
|
|
41
|
+
*/
|
|
42
|
+
export function buildDiff(input: DiffInput): DiffResult {
|
|
43
|
+
assertIRShape(input.baseIR, "baseIR")
|
|
44
|
+
assertIRShape(input.headIR, "headIR")
|
|
45
|
+
ensureSchemasAgree(input.baseIR, input.headIR)
|
|
46
|
+
const stage1 = matchStageId(input.baseIR.symbols, input.headIR.symbols)
|
|
47
|
+
const stage2 = matchStageGitRename(
|
|
48
|
+
stage1.remainingBase,
|
|
49
|
+
stage1.remainingHead,
|
|
50
|
+
input.gitRenames ?? null,
|
|
51
|
+
)
|
|
52
|
+
const stage3 = matchStageLogicFingerprint(stage2.remainingBase, stage2.remainingHead)
|
|
53
|
+
const stage4 = matchStageNameSignature(stage3.remainingBase, stage3.remainingHead)
|
|
54
|
+
const stage4_5 = matchStageDroppedWeak(stage4.remainingBase, stage4.remainingHead)
|
|
55
|
+
|
|
56
|
+
const pairs: SymbolPair[] = [
|
|
57
|
+
...stage1.matched,
|
|
58
|
+
...stage2.matched,
|
|
59
|
+
...stage3.matched,
|
|
60
|
+
...stage4.matched,
|
|
61
|
+
...stage4_5.matched,
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
const summary: Summary = {
|
|
65
|
+
added: 0,
|
|
66
|
+
removed: 0,
|
|
67
|
+
moved: 0,
|
|
68
|
+
movedChanged: 0,
|
|
69
|
+
changed: 0,
|
|
70
|
+
droppedToggled: 0,
|
|
71
|
+
unchanged: 0,
|
|
72
|
+
droppedAdded: 0,
|
|
73
|
+
droppedRemoved: 0,
|
|
74
|
+
componentsAdded: 0,
|
|
75
|
+
componentsRemoved: 0,
|
|
76
|
+
componentsChanged: 0,
|
|
77
|
+
depsAdded: 0,
|
|
78
|
+
depsRemoved: 0,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const symbols: SymbolChange[] = []
|
|
82
|
+
for (const pair of pairs) {
|
|
83
|
+
const status = classifyStatus(pair.base, pair.head)
|
|
84
|
+
if (status === "unchanged") {
|
|
85
|
+
summary.unchanged++
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (status === "dropped-toggled") {
|
|
89
|
+
summary.droppedToggled++
|
|
90
|
+
symbols.push({
|
|
91
|
+
status: "dropped-toggled",
|
|
92
|
+
before: pair.base,
|
|
93
|
+
after: pair.head,
|
|
94
|
+
direction: dropDirection(pair.head),
|
|
95
|
+
})
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
if (status === "moved") {
|
|
99
|
+
summary.moved++
|
|
100
|
+
symbols.push({
|
|
101
|
+
status: "moved",
|
|
102
|
+
before: pair.base,
|
|
103
|
+
after: pair.head,
|
|
104
|
+
rationale: pair.rationale,
|
|
105
|
+
})
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (status === "changed") {
|
|
109
|
+
summary.changed++
|
|
110
|
+
symbols.push({
|
|
111
|
+
status: "changed",
|
|
112
|
+
before: pair.base,
|
|
113
|
+
after: pair.head,
|
|
114
|
+
delta: computeSymbolDelta(pair.base, pair.head, input.delta ?? {}),
|
|
115
|
+
})
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
// moved+changed
|
|
119
|
+
summary.movedChanged++
|
|
120
|
+
symbols.push({
|
|
121
|
+
status: "moved+changed",
|
|
122
|
+
before: pair.base,
|
|
123
|
+
after: pair.head,
|
|
124
|
+
rationale: pair.rationale,
|
|
125
|
+
delta: computeSymbolDelta(pair.base, pair.head, input.delta ?? {}),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const finalRemainingHead = stage4_5.remainingHead
|
|
130
|
+
const finalRemainingBase = stage4_5.remainingBase
|
|
131
|
+
|
|
132
|
+
for (const h of finalRemainingHead) {
|
|
133
|
+
if (h.dropped) summary.droppedAdded++
|
|
134
|
+
else {
|
|
135
|
+
summary.added++
|
|
136
|
+
symbols.push({ status: "added", symbol: h })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const b of finalRemainingBase) {
|
|
140
|
+
if (b.dropped) summary.droppedRemoved++
|
|
141
|
+
else {
|
|
142
|
+
summary.removed++
|
|
143
|
+
symbols.push({ status: "removed", symbol: b })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const components = diffComponents(input.baseIR.components, input.headIR.components)
|
|
148
|
+
summary.componentsAdded = components.added.length
|
|
149
|
+
summary.componentsRemoved = components.removed.length
|
|
150
|
+
summary.componentsChanged = components.changed.length
|
|
151
|
+
|
|
152
|
+
const dependencies = diffDependencies(input.baseIR.dependencies, input.headIR.dependencies)
|
|
153
|
+
summary.depsAdded = dependencies.added.length
|
|
154
|
+
summary.depsRemoved = dependencies.removed.length
|
|
155
|
+
|
|
156
|
+
symbols.sort(compareSymbolChange)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
$schema: DIFF_SCHEMA,
|
|
160
|
+
generator: input.generator ?? DEFAULT_GENERATOR,
|
|
161
|
+
base: input.base,
|
|
162
|
+
head: input.head,
|
|
163
|
+
summary,
|
|
164
|
+
symbols,
|
|
165
|
+
components,
|
|
166
|
+
dependencies,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** §9.1 — refuse to diff across schema versions. */
|
|
171
|
+
function ensureSchemasAgree(base: IR, head: IR): void {
|
|
172
|
+
if (base.$schema !== head.$schema) {
|
|
173
|
+
throw new DiffError(
|
|
174
|
+
`Base IR schema "${base.$schema}" does not match head IR schema "${head.$schema}"; a diff across schema versions is not supported.`,
|
|
175
|
+
{ code: "schema-mismatch", value: base.$schema },
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Top-level shape check. Refuses to enter the 5-stage matcher when a caller hands in an
|
|
182
|
+
* IR-shaped object that is missing one of the collections the matcher requires as an
|
|
183
|
+
* array. Without this, a malformed `baseIR = { symbols: undefined, ... }` would crash
|
|
184
|
+
* deep inside `matchStageId` with `TypeError: undefined is not iterable`, leaving the
|
|
185
|
+
* root cause obscured. Not a full schema validation — just an entry-point smoke test.
|
|
186
|
+
*/
|
|
187
|
+
function assertIRShape(ir: IR, name: "baseIR" | "headIR"): void {
|
|
188
|
+
if (ir === null || typeof ir !== "object") {
|
|
189
|
+
throw new DiffError(`${name} must be an IR object; got ${typeof ir}.`, {
|
|
190
|
+
code: "ir-shape-invalid",
|
|
191
|
+
value: name,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
if (typeof ir.$schema !== "string" || ir.$schema.length === 0) {
|
|
195
|
+
throw new DiffError(`${name}.$schema must be a non-empty schema URL.`, {
|
|
196
|
+
code: "ir-shape-invalid",
|
|
197
|
+
value: `${name}.$schema`,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
for (const field of ["symbols", "components", "dependencies"] as const) {
|
|
201
|
+
if (!Array.isArray(ir[field])) {
|
|
202
|
+
throw new DiffError(`${name}.${field} must be an array.`, {
|
|
203
|
+
code: "ir-shape-invalid",
|
|
204
|
+
value: `${name}.${field}`,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Deterministic ordering of the `symbols[]` output:
|
|
212
|
+
* 1. by status (alphabetical) — pins added/changed/moved sections
|
|
213
|
+
* 2. by the "reference" id (`after` for change/move/toggle, otherwise `symbol`)
|
|
214
|
+
*
|
|
215
|
+
* The result is stable byte-for-byte for equal inputs, matching the canonicalisation
|
|
216
|
+
* guarantee the diff JSON must uphold when persisted to `out/diff.json`.
|
|
217
|
+
*/
|
|
218
|
+
function compareSymbolChange(a: SymbolChange, b: SymbolChange): number {
|
|
219
|
+
if (a.status !== b.status) return a.status < b.status ? -1 : 1
|
|
220
|
+
const idA = referenceId(a)
|
|
221
|
+
const idB = referenceId(b)
|
|
222
|
+
return idA < idB ? -1 : idA > idB ? 1 : 0
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function referenceId(change: SymbolChange): string {
|
|
226
|
+
if (change.status === "added" || change.status === "removed") return change.symbol.id
|
|
227
|
+
return change.after.id
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Byte-deterministic serialiser for a DiffResult. Delegates to `@aburi/core`
|
|
232
|
+
* `serializeCanonical` so the sort order, NFC normalisation, and codepoint key sort are
|
|
233
|
+
* shared with the IR side.
|
|
234
|
+
*/
|
|
235
|
+
export function writeCanonicalDiff(diff: DiffResult, options: SerializeOptions = {}): string {
|
|
236
|
+
return serializeCanonical(diff, options)
|
|
237
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coded error class for every @aburi/diff failure. Consumers branch on `code` without
|
|
3
|
+
* parsing message text. Codes are stable and additions are non-breaking; renames are not.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type DiffErrorCode =
|
|
7
|
+
/** `base.$schema` and `head.$schema` disagree; a diff across schema versions cannot be trusted (§9.1). */
|
|
8
|
+
| "schema-mismatch"
|
|
9
|
+
/** `config.diff.lineFuzz` was outside the documented [0, 10] range (diff-algorithm.md §5.2.1). */
|
|
10
|
+
| "invalid-line-fuzz"
|
|
11
|
+
/**
|
|
12
|
+
* `baseIR` or `headIR` failed the top-level shape check that `buildDiff` enforces
|
|
13
|
+
* before touching any Symbol. Missing / non-array `symbols`, `components`,
|
|
14
|
+
* `dependencies`, or an absent `$schema` — anything that would otherwise crash with a
|
|
15
|
+
* cryptic `TypeError: undefined is not iterable` deep in a matching stage.
|
|
16
|
+
*/
|
|
17
|
+
| "ir-shape-invalid"
|
|
18
|
+
|
|
19
|
+
export interface DiffErrorDetail {
|
|
20
|
+
code: DiffErrorCode
|
|
21
|
+
/** Offending value (schema id, fuzz value, etc.) when applicable. */
|
|
22
|
+
value?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class DiffError extends Error {
|
|
26
|
+
readonly code: DiffErrorCode
|
|
27
|
+
readonly value: string | undefined
|
|
28
|
+
|
|
29
|
+
constructor(message: string, detail: DiffErrorDetail, options?: { cause?: unknown }) {
|
|
30
|
+
super(message, options)
|
|
31
|
+
this.name = "DiffError"
|
|
32
|
+
this.code = detail.code
|
|
33
|
+
this.value = detail.value
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export {
|
|
2
|
+
diffComponents,
|
|
3
|
+
diffDependencies,
|
|
4
|
+
} from "./components"
|
|
5
|
+
export {
|
|
6
|
+
computeSymbolDelta,
|
|
7
|
+
DEFAULT_LINE_FUZZ,
|
|
8
|
+
type DeltaOptions,
|
|
9
|
+
MAX_LINE_FUZZ,
|
|
10
|
+
MIN_LINE_FUZZ,
|
|
11
|
+
} from "./delta"
|
|
12
|
+
export {
|
|
13
|
+
buildDiff,
|
|
14
|
+
type DiffInput,
|
|
15
|
+
writeCanonicalDiff,
|
|
16
|
+
} from "./diff"
|
|
17
|
+
export {
|
|
18
|
+
DiffError,
|
|
19
|
+
type DiffErrorCode,
|
|
20
|
+
type DiffErrorDetail,
|
|
21
|
+
} from "./errors"
|
|
22
|
+
export {
|
|
23
|
+
type GitRenameMap,
|
|
24
|
+
matchStageDroppedWeak,
|
|
25
|
+
matchStageGitRename,
|
|
26
|
+
matchStageId,
|
|
27
|
+
matchStageLogicFingerprint,
|
|
28
|
+
matchStageNameSignature,
|
|
29
|
+
type SymbolPair,
|
|
30
|
+
} from "./match"
|
|
31
|
+
export { signatureSimilarity } from "./signature"
|
|
32
|
+
export {
|
|
33
|
+
jaccard,
|
|
34
|
+
jaccardTokens,
|
|
35
|
+
lastSegment,
|
|
36
|
+
nameSimilarity,
|
|
37
|
+
ownerSimilarity,
|
|
38
|
+
tokenizeName,
|
|
39
|
+
} from "./similarity"
|
|
40
|
+
export {
|
|
41
|
+
classifyStatus,
|
|
42
|
+
type DropDirection,
|
|
43
|
+
dropDirection,
|
|
44
|
+
type SymbolStatus,
|
|
45
|
+
} from "./status"
|
package/src/match.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { Symbol as IRSymbol, MatchRationale } from "@aburi/types"
|
|
2
|
+
import { signatureSimilarity } from "./signature"
|
|
3
|
+
import { lastSegment, nameSimilarity, ownerSimilarity, tokenizeName } from "./similarity"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A concluded pairing of one base Symbol with one head Symbol, along with the
|
|
7
|
+
* `MatchRationale` that placed them in the same pair. Stage 4 uses this shape both to
|
|
8
|
+
* carry results out and to feed the delta / status classifier downstream.
|
|
9
|
+
*/
|
|
10
|
+
export interface SymbolPair {
|
|
11
|
+
base: IRSymbol
|
|
12
|
+
head: IRSymbol
|
|
13
|
+
rationale: MatchRationale
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Optional map returned by git for stage 2 (`old path` → `new path`). */
|
|
17
|
+
export type GitRenameMap = ReadonlyMap<string, string>
|
|
18
|
+
|
|
19
|
+
/** Fingerprint for the 12-hex-char "everything zero" (i.e. dropped) canonical hash. */
|
|
20
|
+
const ZERO_LOGIC_FP = "000000000000"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* §3.1 — segments Symbols with identical `id` into paired matches. Runs first because it
|
|
24
|
+
* is the highest-confidence signal (no heuristics, just hash lookup).
|
|
25
|
+
*/
|
|
26
|
+
export function matchStageId(
|
|
27
|
+
base: readonly IRSymbol[],
|
|
28
|
+
head: readonly IRSymbol[],
|
|
29
|
+
): {
|
|
30
|
+
matched: SymbolPair[]
|
|
31
|
+
remainingBase: IRSymbol[]
|
|
32
|
+
remainingHead: IRSymbol[]
|
|
33
|
+
} {
|
|
34
|
+
const headById = new Map<string, IRSymbol>()
|
|
35
|
+
for (const s of head) headById.set(s.id, s)
|
|
36
|
+
const matched: SymbolPair[] = []
|
|
37
|
+
const remainingBase: IRSymbol[] = []
|
|
38
|
+
const usedHead = new Set<string>()
|
|
39
|
+
for (const b of base) {
|
|
40
|
+
const h = headById.get(b.id)
|
|
41
|
+
if (h !== undefined) {
|
|
42
|
+
matched.push({ base: b, head: h, rationale: "id-match" })
|
|
43
|
+
usedHead.add(b.id)
|
|
44
|
+
} else {
|
|
45
|
+
remainingBase.push(b)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const remainingHead = head.filter((h) => !usedHead.has(h.id))
|
|
49
|
+
return { matched, remainingBase, remainingHead }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* §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.
|
|
55
|
+
*
|
|
56
|
+
* The rewriter only touches the `file` portion of the id — the `<language>:` prefix and
|
|
57
|
+
* the trailing `#<qualified-name>` segment stay unchanged so a moved file with the same
|
|
58
|
+
* qualified name is picked up correctly. When a Symbol's file is not in the rename map,
|
|
59
|
+
* it is left for later stages.
|
|
60
|
+
*/
|
|
61
|
+
export function matchStageGitRename(
|
|
62
|
+
remainingBase: readonly IRSymbol[],
|
|
63
|
+
remainingHead: readonly IRSymbol[],
|
|
64
|
+
renameMap: GitRenameMap | null,
|
|
65
|
+
): {
|
|
66
|
+
matched: SymbolPair[]
|
|
67
|
+
remainingBase: IRSymbol[]
|
|
68
|
+
remainingHead: IRSymbol[]
|
|
69
|
+
} {
|
|
70
|
+
if (renameMap === null || renameMap.size === 0) {
|
|
71
|
+
return { matched: [], remainingBase: [...remainingBase], remainingHead: [...remainingHead] }
|
|
72
|
+
}
|
|
73
|
+
const headById = new Map<string, IRSymbol>()
|
|
74
|
+
for (const h of remainingHead) headById.set(h.id, h)
|
|
75
|
+
const matched: SymbolPair[] = []
|
|
76
|
+
const usedHead = new Set<string>()
|
|
77
|
+
const remaining: IRSymbol[] = []
|
|
78
|
+
for (const b of remainingBase) {
|
|
79
|
+
const newPath = renameMap.get(b.source.file)
|
|
80
|
+
if (newPath === undefined) {
|
|
81
|
+
remaining.push(b)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const expectedId = rewriteIdFile(b.id, b.source.file, newPath)
|
|
85
|
+
const h = headById.get(expectedId)
|
|
86
|
+
if (h === undefined || usedHead.has(h.id)) {
|
|
87
|
+
remaining.push(b)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
matched.push({ base: b, head: h, rationale: "git-rename" })
|
|
91
|
+
usedHead.add(h.id)
|
|
92
|
+
}
|
|
93
|
+
const remainingHeadOut = remainingHead.filter((h) => !usedHead.has(h.id))
|
|
94
|
+
return { matched, remainingBase: remaining, remainingHead: remainingHeadOut }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function rewriteIdFile(id: string, oldPath: string, newPath: string): string {
|
|
98
|
+
const colon = id.indexOf(":")
|
|
99
|
+
const hash = id.indexOf("#")
|
|
100
|
+
if (colon < 0 || hash < 0 || hash < colon) return id
|
|
101
|
+
const language = id.slice(0, colon)
|
|
102
|
+
const filePart = id.slice(colon + 1, hash)
|
|
103
|
+
if (filePart !== oldPath) return id
|
|
104
|
+
const rest = id.slice(hash)
|
|
105
|
+
return `${language}:${newPath}${rest}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* §3.3 — group base Symbols by `fingerprint.logic` and match head Symbols whose logic
|
|
110
|
+
* fingerprint is in the map. Two disambiguation branches:
|
|
111
|
+
* - single candidate → paired with `logic-fingerprint`
|
|
112
|
+
* - multiple candidates → best `nameSimilarity` wins if ≥ 0.85; otherwise leave both
|
|
113
|
+
* sides in remaining for stage 4 to re-evaluate
|
|
114
|
+
*
|
|
115
|
+
* Dropped symbols are excluded (§3.3 tail) — their logic fingerprint is the sentinel
|
|
116
|
+
* `"000000000000"` and would collide with every other dropped Symbol in the workspace.
|
|
117
|
+
* They flow to the stage-4.5 weak matcher instead.
|
|
118
|
+
*/
|
|
119
|
+
export function matchStageLogicFingerprint(
|
|
120
|
+
remainingBase: readonly IRSymbol[],
|
|
121
|
+
remainingHead: readonly IRSymbol[],
|
|
122
|
+
): {
|
|
123
|
+
matched: SymbolPair[]
|
|
124
|
+
remainingBase: IRSymbol[]
|
|
125
|
+
remainingHead: IRSymbol[]
|
|
126
|
+
} {
|
|
127
|
+
const logicMap = new Map<string, IRSymbol[]>()
|
|
128
|
+
const carryBase: IRSymbol[] = []
|
|
129
|
+
for (const b of remainingBase) {
|
|
130
|
+
if (b.dropped || b.fingerprint.logic === ZERO_LOGIC_FP) {
|
|
131
|
+
carryBase.push(b)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
const bucket = logicMap.get(b.fingerprint.logic) ?? []
|
|
135
|
+
bucket.push(b)
|
|
136
|
+
logicMap.set(b.fingerprint.logic, bucket)
|
|
137
|
+
}
|
|
138
|
+
const matched: SymbolPair[] = []
|
|
139
|
+
const carryHead: IRSymbol[] = []
|
|
140
|
+
const usedBaseIds = new Set<string>()
|
|
141
|
+
for (const h of remainingHead) {
|
|
142
|
+
if (h.dropped || h.fingerprint.logic === ZERO_LOGIC_FP) {
|
|
143
|
+
carryHead.push(h)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
const candidates = logicMap.get(h.fingerprint.logic)
|
|
147
|
+
if (candidates === undefined || candidates.length === 0) {
|
|
148
|
+
carryHead.push(h)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if (candidates.length === 1) {
|
|
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
|
|
158
|
+
}
|
|
159
|
+
let best: { symbol: IRSymbol; score: number } | null = null
|
|
160
|
+
for (const c of candidates) {
|
|
161
|
+
const score = nameSimilarity(c.name, h.name)
|
|
162
|
+
if (best === null || score > best.score) best = { symbol: c, score }
|
|
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)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const finalBase: IRSymbol[] = [...carryBase]
|
|
179
|
+
for (const bucket of logicMap.values()) {
|
|
180
|
+
for (const b of bucket) {
|
|
181
|
+
if (!usedBaseIds.has(b.id)) finalBase.push(b)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { matched, remainingBase: finalBase, remainingHead: carryHead }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* §3.4 — name + signature similarity with `(kind, signatureNullness)` bucket pre-filter.
|
|
189
|
+
* `signatureNullness` distinguishes symbols with no signature (`interface`, `type`,
|
|
190
|
+
* `class`) from callable ones so that a class body is never paired against a function.
|
|
191
|
+
*
|
|
192
|
+
* The composite score:
|
|
193
|
+
* 0.5 * nameSimilarity + 0.3 * signatureSimilarity + 0.2 * ownerSimilarity
|
|
194
|
+
*
|
|
195
|
+
* §3.4.3 threshold table:
|
|
196
|
+
* - 1-token name → 1.0 (unreachable → never paired; false-positive shield)
|
|
197
|
+
* - 2-token name → 0.95 (strict, avoids `getUser` vs `getUsers`)
|
|
198
|
+
* - default → 0.85
|
|
199
|
+
*
|
|
200
|
+
* §3.4.3 tail — if both sides are signature-less, we skip pairing entirely because
|
|
201
|
+
* signatureSimilarity always returns 1.0 for `null + null` and would flood the bucket with
|
|
202
|
+
* false pairings.
|
|
203
|
+
*/
|
|
204
|
+
export function matchStageNameSignature(
|
|
205
|
+
remainingBase: readonly IRSymbol[],
|
|
206
|
+
remainingHead: readonly IRSymbol[],
|
|
207
|
+
): {
|
|
208
|
+
matched: SymbolPair[]
|
|
209
|
+
remainingBase: IRSymbol[]
|
|
210
|
+
remainingHead: IRSymbol[]
|
|
211
|
+
} {
|
|
212
|
+
type BucketKey = string
|
|
213
|
+
const buckets = new Map<BucketKey, IRSymbol[]>()
|
|
214
|
+
for (const b of remainingBase) {
|
|
215
|
+
if (b.dropped) continue
|
|
216
|
+
const key = bucketKey(b)
|
|
217
|
+
const bucket = buckets.get(key) ?? []
|
|
218
|
+
bucket.push(b)
|
|
219
|
+
buckets.set(key, bucket)
|
|
220
|
+
}
|
|
221
|
+
const matched: SymbolPair[] = []
|
|
222
|
+
const remainingHeadOut: IRSymbol[] = []
|
|
223
|
+
const usedBaseIds = new Set<string>()
|
|
224
|
+
for (const h of remainingHead) {
|
|
225
|
+
if (h.dropped) {
|
|
226
|
+
remainingHeadOut.push(h)
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
const key = bucketKey(h)
|
|
230
|
+
const bucket = buckets.get(key)
|
|
231
|
+
if (bucket === undefined || bucket.length === 0) {
|
|
232
|
+
remainingHeadOut.push(h)
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
const headSigNull = h.signature === null || h.signature === undefined
|
|
236
|
+
if (headSigNull && bucket.every((c) => c.signature === null || c.signature === undefined)) {
|
|
237
|
+
remainingHeadOut.push(h)
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
let best: { symbol: IRSymbol; score: number } | null = null
|
|
241
|
+
for (const c of bucket) {
|
|
242
|
+
const score =
|
|
243
|
+
0.5 * nameSimilarity(c.name, h.name) +
|
|
244
|
+
0.3 * signatureSimilarity(c.signature ?? null, h.signature ?? null) +
|
|
245
|
+
0.2 * ownerSimilarity(c.name, h.name)
|
|
246
|
+
if (best === null || score > best.score) best = { symbol: c, score }
|
|
247
|
+
}
|
|
248
|
+
const threshold = thresholdFor(h.name)
|
|
249
|
+
if (best !== null && best.score >= threshold) {
|
|
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)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const remainingBaseOut: IRSymbol[] = []
|
|
261
|
+
for (const b of remainingBase) {
|
|
262
|
+
if (b.dropped) {
|
|
263
|
+
remainingBaseOut.push(b)
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
if (!usedBaseIds.has(b.id)) remainingBaseOut.push(b)
|
|
267
|
+
}
|
|
268
|
+
return { matched, remainingBase: remainingBaseOut, remainingHead: remainingHeadOut }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function bucketKey(s: IRSymbol): string {
|
|
272
|
+
const sig = s.signature === null || s.signature === undefined ? "no-sig" : "has-sig"
|
|
273
|
+
return `${s.kind}::${sig}`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function thresholdFor(qname: string): number {
|
|
277
|
+
const tokens = tokenizeName(lastSegment(qname)).length
|
|
278
|
+
if (tokens <= 1) return 1
|
|
279
|
+
if (tokens === 2) return 0.95
|
|
280
|
+
return 0.85
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* §3.4.5 — dropped-only weak matcher. For dropped Symbols the fingerprint is zeroed and
|
|
285
|
+
* name/signature are the only remaining signals. Score is a coarse "did the last name
|
|
286
|
+
* segment or file basename survive?" — if either half matches we pair (threshold 0.5).
|
|
287
|
+
*
|
|
288
|
+
* This is deliberately lax; diff-algorithm.md §3.4.5 already accepts that dropped Symbols live
|
|
289
|
+
* outside the main IR review surface, so occasional false pairings only affect the
|
|
290
|
+
* "Drop-rule variation" fold-out section in the Markdown projection.
|
|
291
|
+
*/
|
|
292
|
+
export function matchStageDroppedWeak(
|
|
293
|
+
remainingBase: readonly IRSymbol[],
|
|
294
|
+
remainingHead: readonly IRSymbol[],
|
|
295
|
+
): {
|
|
296
|
+
matched: SymbolPair[]
|
|
297
|
+
remainingBase: IRSymbol[]
|
|
298
|
+
remainingHead: IRSymbol[]
|
|
299
|
+
} {
|
|
300
|
+
const droppedBase = remainingBase.filter((s) => s.dropped)
|
|
301
|
+
const nonDroppedBase = remainingBase.filter((s) => !s.dropped)
|
|
302
|
+
const droppedHead = remainingHead.filter((s) => s.dropped)
|
|
303
|
+
const nonDroppedHead = remainingHead.filter((s) => !s.dropped)
|
|
304
|
+
const matched: SymbolPair[] = []
|
|
305
|
+
const usedBaseIds = new Set<string>()
|
|
306
|
+
const carryHead: IRSymbol[] = []
|
|
307
|
+
for (const h of droppedHead) {
|
|
308
|
+
let best: { symbol: IRSymbol; score: number } | null = null
|
|
309
|
+
for (const b of droppedBase) {
|
|
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
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const carryBase: IRSymbol[] = [
|
|
325
|
+
...nonDroppedBase,
|
|
326
|
+
...droppedBase.filter((b) => !usedBaseIds.has(b.id)),
|
|
327
|
+
]
|
|
328
|
+
return {
|
|
329
|
+
matched,
|
|
330
|
+
remainingBase: carryBase,
|
|
331
|
+
remainingHead: [...nonDroppedHead, ...carryHead],
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function basename(path: string): string {
|
|
336
|
+
const slash = path.lastIndexOf("/")
|
|
337
|
+
return slash >= 0 ? path.slice(slash + 1) : path
|
|
338
|
+
}
|