@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.
@@ -0,0 +1,53 @@
1
+ import type { Signature } from "@aburi/types"
2
+
3
+ /**
4
+ * §3.4.2 signatureSimilarity — averaged type-similarity across `inputs`, `outputs`, and
5
+ * `throws`. Follows diff-algorithm.md §3.4.2:
6
+ * - null + null → 1.0 (both symbols are signature-less; nothing to compare)
7
+ * - null + non-null (either side) → 0.0
8
+ * - both non-null: mean of three subscores
9
+ *
10
+ * Subscores:
11
+ * - inputs: ordered type-string equality rate (`i-th type equal` counts as 1, else 0)
12
+ * - outputs: same shape, but outputs is an array of type strings
13
+ * - throws: unordered set intersection over union
14
+ *
15
+ * Empty subarrays on both sides count as 1.0 (they are trivially equal); on one side only
16
+ * they count as 0.0 (asymmetric information).
17
+ */
18
+ export function signatureSimilarity(
19
+ base: Signature | null | undefined,
20
+ head: Signature | null | undefined,
21
+ ): number {
22
+ const baseSig = base ?? null
23
+ const headSig = head ?? null
24
+ if (baseSig === null && headSig === null) return 1
25
+ if (baseSig === null || headSig === null) return 0
26
+ const inputsScore = compareOrderedTypes(
27
+ baseSig.inputs.map((i) => i.type),
28
+ headSig.inputs.map((i) => i.type),
29
+ )
30
+ const outputsScore = compareOrderedTypes(baseSig.outputs, headSig.outputs)
31
+ const throwsScore = compareUnorderedSet(baseSig.throws, headSig.throws)
32
+ return (inputsScore + outputsScore + throwsScore) / 3
33
+ }
34
+
35
+ function compareOrderedTypes(a: readonly string[], b: readonly string[]): number {
36
+ if (a.length === 0 && b.length === 0) return 1
37
+ if (a.length !== b.length) return 0
38
+ let matches = 0
39
+ for (let i = 0; i < a.length; i++) {
40
+ if (a[i] === b[i]) matches++
41
+ }
42
+ return matches / a.length
43
+ }
44
+
45
+ function compareUnorderedSet(a: readonly string[], b: readonly string[]): number {
46
+ if (a.length === 0 && b.length === 0) return 1
47
+ const setA = new Set(a)
48
+ const setB = new Set(b)
49
+ let intersection = 0
50
+ for (const t of setA) if (setB.has(t)) intersection++
51
+ const union = setA.size + setB.size - intersection
52
+ return union === 0 ? 1 : intersection / union
53
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * String similarity utilities dedicated to the diff engine. Kept in one file so the
3
+ * name/signature/owner formulas are auditable side by side against diff-algorithm.md §3.4.
4
+ */
5
+
6
+ /**
7
+ * Tokenise a qualified name into normalised lowercase word segments. Splits on:
8
+ * - camelCase / PascalCase boundaries (`fooBar` → `foo`, `Bar`)
9
+ * - snake_case underscores (`foo_bar` → `foo`, `bar`)
10
+ * - dotted namespaces (`ns.Class` → `ns`, `Class`)
11
+ * - `::` static-method separator (`Class::method` → `Class`, `method`)
12
+ * - runs of digits (`get2Users` → `get`, `2`, `Users`)
13
+ *
14
+ * The result is de-duplicated so `InvoiceService.createInvoice` collapses to
15
+ * `["invoice", "service", "create"]` — Jaccard similarity relies on set semantics, so
16
+ * repeated tokens must not double-count.
17
+ */
18
+ export function tokenizeName(input: string): string[] {
19
+ const seen = new Set<string>()
20
+ const out: string[] = []
21
+ for (const raw of input.split(/[.:_\-\s/]+/)) {
22
+ if (raw.length === 0) continue
23
+ for (const piece of splitCamel(raw)) {
24
+ const norm = piece.toLowerCase()
25
+ if (norm.length === 0 || seen.has(norm)) continue
26
+ seen.add(norm)
27
+ out.push(norm)
28
+ }
29
+ }
30
+ return out
31
+ }
32
+
33
+ function splitCamel(word: string): string[] {
34
+ const chunks: string[] = []
35
+ let current = ""
36
+ for (const ch of word) {
37
+ if (current === "") {
38
+ current = ch
39
+ continue
40
+ }
41
+ const prev = current.slice(-1)
42
+ const boundary = isCamelBoundary(prev, ch)
43
+ if (boundary) {
44
+ chunks.push(current)
45
+ current = ch
46
+ } else {
47
+ current += ch
48
+ }
49
+ }
50
+ if (current.length > 0) chunks.push(current)
51
+ return chunks
52
+ }
53
+
54
+ function isCamelBoundary(prev: string, curr: string): boolean {
55
+ const prevLower = prev >= "a" && prev <= "z"
56
+ const prevUpper = prev >= "A" && prev <= "Z"
57
+ const prevDigit = prev >= "0" && prev <= "9"
58
+ const currLower = curr >= "a" && curr <= "z"
59
+ const currUpper = curr >= "A" && curr <= "Z"
60
+ const currDigit = curr >= "0" && curr <= "9"
61
+ if (prevLower && currUpper) return true
62
+ if ((prevLower || prevUpper) && currDigit) return true
63
+ if (prevDigit && (currLower || currUpper)) return true
64
+ return false
65
+ }
66
+
67
+ /**
68
+ * Jaccard similarity between two token multisets. Formula: |A ∩ B| / |A ∪ B|.
69
+ * Empty on both sides returns 1.0 (both are "no tokens", which are equivalent for the
70
+ * purpose of §3.4.1). Empty on one side only returns 0.0.
71
+ */
72
+ export function jaccard(a: readonly string[], b: readonly string[]): number {
73
+ if (a.length === 0 && b.length === 0) return 1
74
+ if (a.length === 0 || b.length === 0) return 0
75
+ const setA = new Set(a)
76
+ const setB = new Set(b)
77
+ let intersection = 0
78
+ for (const t of setA) if (setB.has(t)) intersection++
79
+ const union = setA.size + setB.size - intersection
80
+ return intersection / union
81
+ }
82
+
83
+ /** name-token Jaccard shortcut used by nameSimilarity and ownerSimilarity call sites. */
84
+ export function jaccardTokens(a: string, b: string): number {
85
+ return jaccard(tokenizeName(a), tokenizeName(b))
86
+ }
87
+
88
+ /**
89
+ * §3.4.1 — Jaccard over the tokens of the full qualified name. The last segment is not
90
+ * given extra weight; the entire path contributes uniformly because the intersection is
91
+ * what the score should reward.
92
+ */
93
+ export function nameSimilarity(baseName: string, headName: string): number {
94
+ return jaccardTokens(baseName, headName)
95
+ }
96
+
97
+ /**
98
+ * §3.4.6 — owner similarity: same as nameSimilarity but restricted to everything **before**
99
+ * the last segment. Compensates for the R-8 same-name-different-class collision, where
100
+ * `UserRepo.getUser` should not be pair-swapped with `AdminRepo.getUser`.
101
+ *
102
+ * Owner extraction:
103
+ * - `Class::method` → `Class`
104
+ * - `A.B.C.method` → `A.B.C`
105
+ * - `topLevel` → `` (empty)
106
+ *
107
+ * When both owners are empty (top-level functions), returns 1.0 because both live in the
108
+ * same "no-owner" bucket. When one is empty and the other is not, returns 0.0 because the
109
+ * two Symbols live in structurally different scopes.
110
+ */
111
+ export function ownerSimilarity(baseName: string, headName: string): number {
112
+ const baseOwner = extractOwner(baseName)
113
+ const headOwner = extractOwner(headName)
114
+ if (baseOwner === "" && headOwner === "") return 1
115
+ if (baseOwner === "" || headOwner === "") return 0
116
+ return jaccardTokens(baseOwner, headOwner)
117
+ }
118
+
119
+ function extractOwner(qname: string): string {
120
+ const staticIdx = qname.indexOf("::")
121
+ if (staticIdx >= 0) return qname.slice(0, staticIdx)
122
+ const lastDot = qname.lastIndexOf(".")
123
+ if (lastDot >= 0) return qname.slice(0, lastDot)
124
+ return ""
125
+ }
126
+
127
+ /** §3.4.1 tail note — used by drop-4.5 weak matcher and threshold lookup. */
128
+ export function lastSegment(qname: string): string {
129
+ const staticIdx = qname.indexOf("::")
130
+ if (staticIdx >= 0) return qname.slice(staticIdx + 2)
131
+ const lastDot = qname.lastIndexOf(".")
132
+ if (lastDot >= 0) return qname.slice(lastDot + 1)
133
+ return qname
134
+ }
package/src/status.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Symbol as IRSymbol } from "@aburi/types"
2
+
3
+ export type SymbolStatus = "unchanged" | "moved" | "changed" | "moved+changed" | "dropped-toggled"
4
+
5
+ /**
6
+ * §4 — determine which of the five statuses a matched pair falls into. `dropped-toggled`
7
+ * has absolute priority: whenever `dropped` differs between base and head, every other
8
+ * signal is ignored because the underlying fingerprint change is a consequence of the
9
+ * drop-rule flip, not a genuine code edit (§4.1 rationale).
10
+ *
11
+ * "pathChanged" catches both physical file relocation AND in-file rename: any Symbol id
12
+ * mismatch between base and head means the identifier moved, and DF9 ("method 名 rename
13
+ * (同 file、同 logic) → moved") relies on this. Stage-1 pairs cannot trigger the id branch
14
+ * (they match by exact id), so this only affects stage-2+ pairings where the pair was
15
+ * discovered via fingerprint or name/signature similarity.
16
+ */
17
+ export function classifyStatus(base: IRSymbol, head: IRSymbol): SymbolStatus {
18
+ const droppedToggled = base.dropped !== head.dropped
19
+ if (droppedToggled) return "dropped-toggled"
20
+ const pathChanged = base.source.file !== head.source.file || base.id !== head.id
21
+ const fingerprintChanged =
22
+ base.fingerprint.api !== head.fingerprint.api ||
23
+ base.fingerprint.logic !== head.fingerprint.logic ||
24
+ base.fingerprint.syntax !== head.fingerprint.syntax
25
+ if (pathChanged && fingerprintChanged) return "moved+changed"
26
+ if (pathChanged) return "moved"
27
+ if (fingerprintChanged) return "changed"
28
+ return "unchanged"
29
+ }
30
+
31
+ /** Direction of a `dropped-toggled` transition (§7.1 SymbolDroppedToggled). */
32
+ export type DropDirection = "to-dropped" | "to-kept"
33
+
34
+ export function dropDirection(head: IRSymbol): DropDirection {
35
+ return head.dropped ? "to-dropped" : "to-kept"
36
+ }