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