@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/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
+ }