@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/dist/index.mjs
ADDED
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
import { serializeCanonical } from "@aburi/core";
|
|
2
|
+
|
|
3
|
+
//#region src/components.ts
|
|
4
|
+
/**
|
|
5
|
+
* §6.1 — Component diff. Component identity is `id`; when a Component is present in both
|
|
6
|
+
* base and head with the same id, the three delta booleans describe which axes actually
|
|
7
|
+
* moved (roots reshuffle, public API globs, or framework hint list). `modified` deltas
|
|
8
|
+
* are intentionally absent from this diff (§5.2.3): fields are reported as before/after
|
|
9
|
+
* pairs via the `changed[]` entries so consumers can render them without stringifying
|
|
10
|
+
* arrays.
|
|
11
|
+
*/
|
|
12
|
+
function diffComponents(base, head) {
|
|
13
|
+
const baseById = /* @__PURE__ */ new Map();
|
|
14
|
+
for (const c of base) baseById.set(c.id, c);
|
|
15
|
+
const headById = /* @__PURE__ */ new Map();
|
|
16
|
+
for (const c of head) headById.set(c.id, c);
|
|
17
|
+
const added = [];
|
|
18
|
+
const removed = [];
|
|
19
|
+
const changed = [];
|
|
20
|
+
for (const [id, headComp] of headById) {
|
|
21
|
+
const baseComp = baseById.get(id);
|
|
22
|
+
if (baseComp === void 0) {
|
|
23
|
+
added.push(headComp);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const rootsChanged = !stringArraysEqual(baseComp.roots, headComp.roots);
|
|
27
|
+
const publicApiChanged = !stringArraysEqual(baseComp.publicApi ?? [], headComp.publicApi ?? []);
|
|
28
|
+
const frameworksChanged = !stringArraysEqual(baseComp.frameworks ?? [], headComp.frameworks ?? []);
|
|
29
|
+
if (rootsChanged || publicApiChanged || frameworksChanged) changed.push({
|
|
30
|
+
before: baseComp,
|
|
31
|
+
after: headComp,
|
|
32
|
+
delta: {
|
|
33
|
+
rootsChanged,
|
|
34
|
+
publicApiChanged,
|
|
35
|
+
frameworksChanged
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
for (const [id, baseComp] of baseById) if (!headById.has(id)) removed.push(baseComp);
|
|
40
|
+
sortById(added);
|
|
41
|
+
sortById(removed);
|
|
42
|
+
changed.sort((a, b) => a.after.id < b.after.id ? -1 : a.after.id > b.after.id ? 1 : 0);
|
|
43
|
+
return {
|
|
44
|
+
added,
|
|
45
|
+
removed,
|
|
46
|
+
changed
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* §6.2 — Dependency diff. Identity is the composite `(from, to, via)` triple; direction
|
|
51
|
+
* and effect changes are surfaced as an added + removed pair so `modified` is not part of
|
|
52
|
+
* the schema (§6.2 tail).
|
|
53
|
+
*/
|
|
54
|
+
function diffDependencies(base, head) {
|
|
55
|
+
const baseKeys = /* @__PURE__ */ new Map();
|
|
56
|
+
for (const d of base) baseKeys.set(dependencyKey(d), d);
|
|
57
|
+
const headKeys = /* @__PURE__ */ new Map();
|
|
58
|
+
for (const d of head) headKeys.set(dependencyKey(d), d);
|
|
59
|
+
const added = [];
|
|
60
|
+
const removed = [];
|
|
61
|
+
for (const [key, dep] of headKeys) {
|
|
62
|
+
const b = baseKeys.get(key);
|
|
63
|
+
if (b === void 0) {
|
|
64
|
+
added.push(dep);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (b.direction !== dep.direction || (b.effect ?? null) !== (dep.effect ?? null)) {
|
|
68
|
+
removed.push(b);
|
|
69
|
+
added.push(dep);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const [key, dep] of baseKeys) if (!headKeys.has(key)) removed.push(dep);
|
|
73
|
+
added.sort(compareDependencies);
|
|
74
|
+
removed.sort(compareDependencies);
|
|
75
|
+
return {
|
|
76
|
+
added,
|
|
77
|
+
removed
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function dependencyKey(d) {
|
|
81
|
+
return `${d.from}::${d.to}::${d.via}`;
|
|
82
|
+
}
|
|
83
|
+
function compareDependencies(a, b) {
|
|
84
|
+
const keyA = dependencyKey(a);
|
|
85
|
+
const keyB = dependencyKey(b);
|
|
86
|
+
return keyA < keyB ? -1 : keyA > keyB ? 1 : 0;
|
|
87
|
+
}
|
|
88
|
+
function stringArraysEqual(a, b) {
|
|
89
|
+
if (a.length !== b.length) return false;
|
|
90
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
function sortById(items) {
|
|
94
|
+
items.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/errors.ts
|
|
99
|
+
var DiffError = class extends Error {
|
|
100
|
+
code;
|
|
101
|
+
value;
|
|
102
|
+
constructor(message, detail, options) {
|
|
103
|
+
super(message, options);
|
|
104
|
+
this.name = "DiffError";
|
|
105
|
+
this.code = detail.code;
|
|
106
|
+
this.value = detail.value;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/delta.ts
|
|
112
|
+
const DEFAULT_LINE_FUZZ = 2;
|
|
113
|
+
const MAX_LINE_FUZZ = 10;
|
|
114
|
+
const MIN_LINE_FUZZ = 0;
|
|
115
|
+
/**
|
|
116
|
+
* §5 — Compute the full per-Symbol delta between two paired Symbols. The three axis
|
|
117
|
+
* booleans (`apiChanged` / `logicChanged` / `syntaxChanged`) come from fingerprint
|
|
118
|
+
* comparison; the array deltas (`rules` / `effects` / `calls` / `decorators`) come from
|
|
119
|
+
* identity-preserving diff with configurable line fuzz.
|
|
120
|
+
*/
|
|
121
|
+
function computeSymbolDelta(base, head, options = {}) {
|
|
122
|
+
const fuzz = validateLineFuzz(options.lineFuzz ?? 2);
|
|
123
|
+
const delta = {
|
|
124
|
+
apiChanged: base.fingerprint.api !== head.fingerprint.api,
|
|
125
|
+
logicChanged: base.fingerprint.logic !== head.fingerprint.logic,
|
|
126
|
+
syntaxChanged: base.fingerprint.syntax !== head.fingerprint.syntax,
|
|
127
|
+
componentChanged: (base.component ?? null) !== (head.component ?? null),
|
|
128
|
+
visibilityChanged: base.visibility !== head.visibility
|
|
129
|
+
};
|
|
130
|
+
delta.rules = diffRules(base.rules, head.rules, fuzz);
|
|
131
|
+
delta.effects = diffEffects(base.effects, head.effects);
|
|
132
|
+
delta.calls = diffCalls(base.calls, head.calls, fuzz);
|
|
133
|
+
delta.decorators = diffDecorators(base.decorators, head.decorators, fuzz);
|
|
134
|
+
delta.signature = diffSignature(base.signature ?? null, head.signature ?? null);
|
|
135
|
+
return delta;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* §5.2.1 range check. Loud on violation rather than silently clamping so a config typo
|
|
139
|
+
* (`lineFuzz: 999`) or an upstream numeric bug (`NaN`, `Infinity`) surfaces at the diff
|
|
140
|
+
* boundary instead of quietly rounding into a range that produces the wrong deltas.
|
|
141
|
+
*/
|
|
142
|
+
function validateLineFuzz(value) {
|
|
143
|
+
if (!Number.isFinite(value) || !Number.isInteger(value)) throw new DiffError(`config.diff.lineFuzz must be an integer in [${0}, ${10}]; got ${String(value)}.`, {
|
|
144
|
+
code: "invalid-line-fuzz",
|
|
145
|
+
value: String(value)
|
|
146
|
+
});
|
|
147
|
+
if (value < 0 || value > 10) throw new DiffError(`config.diff.lineFuzz must be within [${0}, ${10}]; got ${value}.`, {
|
|
148
|
+
code: "invalid-line-fuzz",
|
|
149
|
+
value: String(value)
|
|
150
|
+
});
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Two-index diff: build key-indexed maps for both sides, then classify each key into
|
|
155
|
+
* `added` / `removed` / `modified`. Line-fuzzed identities are matched within tolerance
|
|
156
|
+
* so cosmetic line shifts do not produce added/removed noise. `modified` only fires when
|
|
157
|
+
* the identity key stays the same but the content differs.
|
|
158
|
+
*/
|
|
159
|
+
function differentiate(base, head, isEqual, lineFuzz) {
|
|
160
|
+
const added = [];
|
|
161
|
+
const removed = [];
|
|
162
|
+
const modified = [];
|
|
163
|
+
const consumedBase = /* @__PURE__ */ new Set();
|
|
164
|
+
for (const h of head) {
|
|
165
|
+
let matchIdx = -1;
|
|
166
|
+
for (let j = 0; j < base.length; j++) {
|
|
167
|
+
if (consumedBase.has(j)) continue;
|
|
168
|
+
const b = base[j];
|
|
169
|
+
if (b === void 0) continue;
|
|
170
|
+
if (b.key !== h.key) continue;
|
|
171
|
+
if (Math.abs(b.line - h.line) > lineFuzz) continue;
|
|
172
|
+
matchIdx = j;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
if (matchIdx === -1) {
|
|
176
|
+
added.push(h.item);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
consumedBase.add(matchIdx);
|
|
180
|
+
const bmatch = base[matchIdx];
|
|
181
|
+
if (bmatch === void 0) continue;
|
|
182
|
+
if (!isEqual(bmatch.item, h.item)) modified.push(h.item);
|
|
183
|
+
}
|
|
184
|
+
for (let j = 0; j < base.length; j++) {
|
|
185
|
+
if (consumedBase.has(j)) continue;
|
|
186
|
+
const b = base[j];
|
|
187
|
+
if (b === void 0) continue;
|
|
188
|
+
removed.push(b.item);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
added,
|
|
192
|
+
removed,
|
|
193
|
+
modified
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function diffRules(base, head, lineFuzz) {
|
|
197
|
+
const mapper = (r) => ({
|
|
198
|
+
item: r,
|
|
199
|
+
key: r.type,
|
|
200
|
+
line: r.line
|
|
201
|
+
});
|
|
202
|
+
return differentiate(base.map(mapper), head.map(mapper), rulesEqual, lineFuzz);
|
|
203
|
+
}
|
|
204
|
+
function rulesEqual(a, b) {
|
|
205
|
+
return a.type === b.type && (a.condition ?? null) === (b.condition ?? null) && (a.what ?? null) === (b.what ?? null) && (a.expr ?? null) === (b.expr ?? null) && (a.loopKind ?? null) === (b.loopKind ?? null);
|
|
206
|
+
}
|
|
207
|
+
function diffEffects(base, head) {
|
|
208
|
+
const mapper = (e) => ({
|
|
209
|
+
item: e,
|
|
210
|
+
key: `${e.id}::${e.target}`,
|
|
211
|
+
line: e.line
|
|
212
|
+
});
|
|
213
|
+
return differentiate(base.map(mapper), head.map(mapper), effectsEqual, Number.POSITIVE_INFINITY);
|
|
214
|
+
}
|
|
215
|
+
function effectsEqual(a, b) {
|
|
216
|
+
return a.id === b.id && a.target === b.target && a.plugin === b.plugin && a.confidence === b.confidence;
|
|
217
|
+
}
|
|
218
|
+
function diffCalls(base, head, lineFuzz) {
|
|
219
|
+
const mapper = (c) => ({
|
|
220
|
+
item: c,
|
|
221
|
+
key: c.target,
|
|
222
|
+
line: c.line
|
|
223
|
+
});
|
|
224
|
+
return differentiate(base.map(mapper), head.map(mapper), callsEqual, lineFuzz);
|
|
225
|
+
}
|
|
226
|
+
function callsEqual(a, b) {
|
|
227
|
+
return a.target === b.target && (a.resolved ?? null) === (b.resolved ?? null);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* §5.2.2 — decorator diff. Identity is `name`; when the same-named decorator is present
|
|
231
|
+
* on both sides within the line-fuzz window, the argument list determines whether the
|
|
232
|
+
* entry is a modified pair or an implicit no-op.
|
|
233
|
+
*/
|
|
234
|
+
function diffDecorators(base, head, lineFuzz) {
|
|
235
|
+
const mapper = (d) => ({
|
|
236
|
+
item: d,
|
|
237
|
+
key: d.name,
|
|
238
|
+
line: d.line
|
|
239
|
+
});
|
|
240
|
+
return differentiate(base.map(mapper), head.map(mapper), decoratorsEqual, lineFuzz);
|
|
241
|
+
}
|
|
242
|
+
function decoratorsEqual(a, b) {
|
|
243
|
+
if (a.name !== b.name) return false;
|
|
244
|
+
if (a.arguments.length !== b.arguments.length) return false;
|
|
245
|
+
for (let i = 0; i < a.arguments.length; i++) if (a.arguments[i] !== b.arguments[i]) return false;
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* §5.3 — signature delta. Three cases:
|
|
250
|
+
* - both sides `null` → `null` (nothing to compare)
|
|
251
|
+
* - exactly one side `null` → the present side is emitted verbatim as
|
|
252
|
+
* `added` (when only head has a signature) or `removed` (when only base has one);
|
|
253
|
+
* the three axis booleans compare the missing side against `false` defaults
|
|
254
|
+
* - both sides non-null → per-list sub-deltas:
|
|
255
|
+
* - `inputs`: strict positional compare via `differentiate(..., lineFuzz: 0)` — index
|
|
256
|
+
* used as the line key so ordered parameter lists match position-for-position
|
|
257
|
+
* - `outputs`: positional compare (added/removed emitted when the arrays diverge at
|
|
258
|
+
* an index), no `modified` category
|
|
259
|
+
* - `throws`: unordered set diff — duplicates collapse per Set semantics
|
|
260
|
+
* - `async` / `generator` / `typeParameters` change flags follow the raw fields
|
|
261
|
+
*/
|
|
262
|
+
function diffSignature(base, head) {
|
|
263
|
+
if (base === null && head === null) return null;
|
|
264
|
+
if (base === null || head === null) {
|
|
265
|
+
const empty = {
|
|
266
|
+
added: [],
|
|
267
|
+
removed: [],
|
|
268
|
+
modified: []
|
|
269
|
+
};
|
|
270
|
+
const present = base ?? head;
|
|
271
|
+
if (present === null) return null;
|
|
272
|
+
const isBaseNull = base === null;
|
|
273
|
+
return {
|
|
274
|
+
inputs: isBaseNull ? {
|
|
275
|
+
added: [...present.inputs],
|
|
276
|
+
removed: [],
|
|
277
|
+
modified: []
|
|
278
|
+
} : {
|
|
279
|
+
added: [],
|
|
280
|
+
removed: [...present.inputs],
|
|
281
|
+
modified: []
|
|
282
|
+
},
|
|
283
|
+
outputs: isBaseNull ? {
|
|
284
|
+
added: [...present.outputs],
|
|
285
|
+
removed: [],
|
|
286
|
+
modified: []
|
|
287
|
+
} : {
|
|
288
|
+
added: [],
|
|
289
|
+
removed: [...present.outputs],
|
|
290
|
+
modified: []
|
|
291
|
+
},
|
|
292
|
+
throws: isBaseNull ? {
|
|
293
|
+
added: [...present.throws],
|
|
294
|
+
removed: [],
|
|
295
|
+
modified: []
|
|
296
|
+
} : {
|
|
297
|
+
added: [],
|
|
298
|
+
removed: [...present.throws],
|
|
299
|
+
modified: []
|
|
300
|
+
},
|
|
301
|
+
asyncChanged: (base?.async ?? false) !== (head?.async ?? false),
|
|
302
|
+
generatorChanged: (base?.generator ?? false) !== (head?.generator ?? false),
|
|
303
|
+
typeParametersChanged: !arraysEqual(base?.typeParameters ?? empty.added.map(String), head?.typeParameters ?? empty.added.map(String))
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const inputMapper = (input, index) => ({
|
|
307
|
+
item: input,
|
|
308
|
+
key: `${index}:${input.name}`,
|
|
309
|
+
line: index
|
|
310
|
+
});
|
|
311
|
+
return {
|
|
312
|
+
inputs: differentiate(base.inputs.map(inputMapper), head.inputs.map(inputMapper), (a, b) => a.name === b.name && a.type === b.type, 0),
|
|
313
|
+
outputs: diffStringList(base.outputs, head.outputs),
|
|
314
|
+
throws: diffStringSet(base.throws, head.throws),
|
|
315
|
+
asyncChanged: base.async !== head.async,
|
|
316
|
+
generatorChanged: base.generator !== head.generator,
|
|
317
|
+
typeParametersChanged: !arraysEqual(base.typeParameters, head.typeParameters)
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function diffStringList(base, head) {
|
|
321
|
+
const added = [];
|
|
322
|
+
const removed = [];
|
|
323
|
+
const max = Math.max(base.length, head.length);
|
|
324
|
+
for (let i = 0; i < max; i++) {
|
|
325
|
+
const b = i < base.length ? base[i] : void 0;
|
|
326
|
+
const h = i < head.length ? head[i] : void 0;
|
|
327
|
+
if (b === void 0 && h !== void 0) added.push(h);
|
|
328
|
+
else if (h === void 0 && b !== void 0) removed.push(b);
|
|
329
|
+
else if (b !== h) {
|
|
330
|
+
if (b !== void 0) removed.push(b);
|
|
331
|
+
if (h !== void 0) added.push(h);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
added,
|
|
336
|
+
removed,
|
|
337
|
+
modified: []
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function diffStringSet(base, head) {
|
|
341
|
+
const setB = new Set(base);
|
|
342
|
+
const setH = new Set(head);
|
|
343
|
+
const added = [];
|
|
344
|
+
const removed = [];
|
|
345
|
+
for (const h of setH) if (!setB.has(h)) added.push(h);
|
|
346
|
+
for (const b of setB) if (!setH.has(b)) removed.push(b);
|
|
347
|
+
return {
|
|
348
|
+
added,
|
|
349
|
+
removed,
|
|
350
|
+
modified: []
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function arraysEqual(a, b) {
|
|
354
|
+
if (a.length !== b.length) return false;
|
|
355
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/signature.ts
|
|
361
|
+
/**
|
|
362
|
+
* §3.4.2 signatureSimilarity — averaged type-similarity across `inputs`, `outputs`, and
|
|
363
|
+
* `throws`. Follows diff-algorithm.md §3.4.2:
|
|
364
|
+
* - null + null → 1.0 (both symbols are signature-less; nothing to compare)
|
|
365
|
+
* - null + non-null (either side) → 0.0
|
|
366
|
+
* - both non-null: mean of three subscores
|
|
367
|
+
*
|
|
368
|
+
* Subscores:
|
|
369
|
+
* - inputs: ordered type-string equality rate (`i-th type equal` counts as 1, else 0)
|
|
370
|
+
* - outputs: same shape, but outputs is an array of type strings
|
|
371
|
+
* - throws: unordered set intersection over union
|
|
372
|
+
*
|
|
373
|
+
* Empty subarrays on both sides count as 1.0 (they are trivially equal); on one side only
|
|
374
|
+
* they count as 0.0 (asymmetric information).
|
|
375
|
+
*/
|
|
376
|
+
function signatureSimilarity(base, head) {
|
|
377
|
+
const baseSig = base ?? null;
|
|
378
|
+
const headSig = head ?? null;
|
|
379
|
+
if (baseSig === null && headSig === null) return 1;
|
|
380
|
+
if (baseSig === null || headSig === null) return 0;
|
|
381
|
+
const inputsScore = compareOrderedTypes(baseSig.inputs.map((i) => i.type), headSig.inputs.map((i) => i.type));
|
|
382
|
+
const outputsScore = compareOrderedTypes(baseSig.outputs, headSig.outputs);
|
|
383
|
+
const throwsScore = compareUnorderedSet(baseSig.throws, headSig.throws);
|
|
384
|
+
return (inputsScore + outputsScore + throwsScore) / 3;
|
|
385
|
+
}
|
|
386
|
+
function compareOrderedTypes(a, b) {
|
|
387
|
+
if (a.length === 0 && b.length === 0) return 1;
|
|
388
|
+
if (a.length !== b.length) return 0;
|
|
389
|
+
let matches = 0;
|
|
390
|
+
for (let i = 0; i < a.length; i++) if (a[i] === b[i]) matches++;
|
|
391
|
+
return matches / a.length;
|
|
392
|
+
}
|
|
393
|
+
function compareUnorderedSet(a, b) {
|
|
394
|
+
if (a.length === 0 && b.length === 0) return 1;
|
|
395
|
+
const setA = new Set(a);
|
|
396
|
+
const setB = new Set(b);
|
|
397
|
+
let intersection = 0;
|
|
398
|
+
for (const t of setA) if (setB.has(t)) intersection++;
|
|
399
|
+
const union = setA.size + setB.size - intersection;
|
|
400
|
+
return union === 0 ? 1 : intersection / union;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/similarity.ts
|
|
405
|
+
/**
|
|
406
|
+
* String similarity utilities dedicated to the diff engine. Kept in one file so the
|
|
407
|
+
* name/signature/owner formulas are auditable side by side against diff-algorithm.md §3.4.
|
|
408
|
+
*/
|
|
409
|
+
/**
|
|
410
|
+
* Tokenise a qualified name into normalised lowercase word segments. Splits on:
|
|
411
|
+
* - camelCase / PascalCase boundaries (`fooBar` → `foo`, `Bar`)
|
|
412
|
+
* - snake_case underscores (`foo_bar` → `foo`, `bar`)
|
|
413
|
+
* - dotted namespaces (`ns.Class` → `ns`, `Class`)
|
|
414
|
+
* - `::` static-method separator (`Class::method` → `Class`, `method`)
|
|
415
|
+
* - runs of digits (`get2Users` → `get`, `2`, `Users`)
|
|
416
|
+
*
|
|
417
|
+
* The result is de-duplicated so `InvoiceService.createInvoice` collapses to
|
|
418
|
+
* `["invoice", "service", "create"]` — Jaccard similarity relies on set semantics, so
|
|
419
|
+
* repeated tokens must not double-count.
|
|
420
|
+
*/
|
|
421
|
+
function tokenizeName(input) {
|
|
422
|
+
const seen = /* @__PURE__ */ new Set();
|
|
423
|
+
const out = [];
|
|
424
|
+
for (const raw of input.split(/[.:_\-\s/]+/)) {
|
|
425
|
+
if (raw.length === 0) continue;
|
|
426
|
+
for (const piece of splitCamel(raw)) {
|
|
427
|
+
const norm = piece.toLowerCase();
|
|
428
|
+
if (norm.length === 0 || seen.has(norm)) continue;
|
|
429
|
+
seen.add(norm);
|
|
430
|
+
out.push(norm);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
function splitCamel(word) {
|
|
436
|
+
const chunks = [];
|
|
437
|
+
let current = "";
|
|
438
|
+
for (const ch of word) {
|
|
439
|
+
if (current === "") {
|
|
440
|
+
current = ch;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (isCamelBoundary(current.slice(-1), ch)) {
|
|
444
|
+
chunks.push(current);
|
|
445
|
+
current = ch;
|
|
446
|
+
} else current += ch;
|
|
447
|
+
}
|
|
448
|
+
if (current.length > 0) chunks.push(current);
|
|
449
|
+
return chunks;
|
|
450
|
+
}
|
|
451
|
+
function isCamelBoundary(prev, curr) {
|
|
452
|
+
const prevLower = prev >= "a" && prev <= "z";
|
|
453
|
+
const prevUpper = prev >= "A" && prev <= "Z";
|
|
454
|
+
const prevDigit = prev >= "0" && prev <= "9";
|
|
455
|
+
const currLower = curr >= "a" && curr <= "z";
|
|
456
|
+
const currUpper = curr >= "A" && curr <= "Z";
|
|
457
|
+
const currDigit = curr >= "0" && curr <= "9";
|
|
458
|
+
if (prevLower && currUpper) return true;
|
|
459
|
+
if ((prevLower || prevUpper) && currDigit) return true;
|
|
460
|
+
if (prevDigit && (currLower || currUpper)) return true;
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Jaccard similarity between two token multisets. Formula: |A ∩ B| / |A ∪ B|.
|
|
465
|
+
* Empty on both sides returns 1.0 (both are "no tokens", which are equivalent for the
|
|
466
|
+
* purpose of §3.4.1). Empty on one side only returns 0.0.
|
|
467
|
+
*/
|
|
468
|
+
function jaccard(a, b) {
|
|
469
|
+
if (a.length === 0 && b.length === 0) return 1;
|
|
470
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
471
|
+
const setA = new Set(a);
|
|
472
|
+
const setB = new Set(b);
|
|
473
|
+
let intersection = 0;
|
|
474
|
+
for (const t of setA) if (setB.has(t)) intersection++;
|
|
475
|
+
const union = setA.size + setB.size - intersection;
|
|
476
|
+
return intersection / union;
|
|
477
|
+
}
|
|
478
|
+
/** name-token Jaccard shortcut used by nameSimilarity and ownerSimilarity call sites. */
|
|
479
|
+
function jaccardTokens(a, b) {
|
|
480
|
+
return jaccard(tokenizeName(a), tokenizeName(b));
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* §3.4.1 — Jaccard over the tokens of the full qualified name. The last segment is not
|
|
484
|
+
* given extra weight; the entire path contributes uniformly because the intersection is
|
|
485
|
+
* what the score should reward.
|
|
486
|
+
*/
|
|
487
|
+
function nameSimilarity(baseName, headName) {
|
|
488
|
+
return jaccardTokens(baseName, headName);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* §3.4.6 — owner similarity: same as nameSimilarity but restricted to everything **before**
|
|
492
|
+
* the last segment. Compensates for the R-8 same-name-different-class collision, where
|
|
493
|
+
* `UserRepo.getUser` should not be pair-swapped with `AdminRepo.getUser`.
|
|
494
|
+
*
|
|
495
|
+
* Owner extraction:
|
|
496
|
+
* - `Class::method` → `Class`
|
|
497
|
+
* - `A.B.C.method` → `A.B.C`
|
|
498
|
+
* - `topLevel` → `` (empty)
|
|
499
|
+
*
|
|
500
|
+
* When both owners are empty (top-level functions), returns 1.0 because both live in the
|
|
501
|
+
* same "no-owner" bucket. When one is empty and the other is not, returns 0.0 because the
|
|
502
|
+
* two Symbols live in structurally different scopes.
|
|
503
|
+
*/
|
|
504
|
+
function ownerSimilarity(baseName, headName) {
|
|
505
|
+
const baseOwner = extractOwner(baseName);
|
|
506
|
+
const headOwner = extractOwner(headName);
|
|
507
|
+
if (baseOwner === "" && headOwner === "") return 1;
|
|
508
|
+
if (baseOwner === "" || headOwner === "") return 0;
|
|
509
|
+
return jaccardTokens(baseOwner, headOwner);
|
|
510
|
+
}
|
|
511
|
+
function extractOwner(qname) {
|
|
512
|
+
const staticIdx = qname.indexOf("::");
|
|
513
|
+
if (staticIdx >= 0) return qname.slice(0, staticIdx);
|
|
514
|
+
const lastDot = qname.lastIndexOf(".");
|
|
515
|
+
if (lastDot >= 0) return qname.slice(0, lastDot);
|
|
516
|
+
return "";
|
|
517
|
+
}
|
|
518
|
+
/** §3.4.1 tail note — used by drop-4.5 weak matcher and threshold lookup. */
|
|
519
|
+
function lastSegment(qname) {
|
|
520
|
+
const staticIdx = qname.indexOf("::");
|
|
521
|
+
if (staticIdx >= 0) return qname.slice(staticIdx + 2);
|
|
522
|
+
const lastDot = qname.lastIndexOf(".");
|
|
523
|
+
if (lastDot >= 0) return qname.slice(lastDot + 1);
|
|
524
|
+
return qname;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/match.ts
|
|
529
|
+
/** Fingerprint for the 12-hex-char "everything zero" (i.e. dropped) canonical hash. */
|
|
530
|
+
const ZERO_LOGIC_FP = "000000000000";
|
|
531
|
+
/**
|
|
532
|
+
* §3.1 — segments Symbols with identical `id` into paired matches. Runs first because it
|
|
533
|
+
* is the highest-confidence signal (no heuristics, just hash lookup).
|
|
534
|
+
*/
|
|
535
|
+
function matchStageId(base, head) {
|
|
536
|
+
const headById = /* @__PURE__ */ new Map();
|
|
537
|
+
for (const s of head) headById.set(s.id, s);
|
|
538
|
+
const matched = [];
|
|
539
|
+
const remainingBase = [];
|
|
540
|
+
const usedHead = /* @__PURE__ */ new Set();
|
|
541
|
+
for (const b of base) {
|
|
542
|
+
const h = headById.get(b.id);
|
|
543
|
+
if (h !== void 0) {
|
|
544
|
+
matched.push({
|
|
545
|
+
base: b,
|
|
546
|
+
head: h,
|
|
547
|
+
rationale: "id-match"
|
|
548
|
+
});
|
|
549
|
+
usedHead.add(b.id);
|
|
550
|
+
} else remainingBase.push(b);
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
matched,
|
|
554
|
+
remainingBase,
|
|
555
|
+
remainingHead: head.filter((h) => !usedHead.has(h.id))
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* §3.2 — git rename detection. When a rename map is available (typical of ref-driven
|
|
560
|
+
* scans), rewrite the base id with the head-side file path and look for a hit.
|
|
561
|
+
*
|
|
562
|
+
* The rewriter only touches the `file` portion of the id — the `<language>:` prefix and
|
|
563
|
+
* the trailing `#<qualified-name>` segment stay unchanged so a moved file with the same
|
|
564
|
+
* qualified name is picked up correctly. When a Symbol's file is not in the rename map,
|
|
565
|
+
* it is left for later stages.
|
|
566
|
+
*/
|
|
567
|
+
function matchStageGitRename(remainingBase, remainingHead, renameMap) {
|
|
568
|
+
if (renameMap === null || renameMap.size === 0) return {
|
|
569
|
+
matched: [],
|
|
570
|
+
remainingBase: [...remainingBase],
|
|
571
|
+
remainingHead: [...remainingHead]
|
|
572
|
+
};
|
|
573
|
+
const headById = /* @__PURE__ */ new Map();
|
|
574
|
+
for (const h of remainingHead) headById.set(h.id, h);
|
|
575
|
+
const matched = [];
|
|
576
|
+
const usedHead = /* @__PURE__ */ new Set();
|
|
577
|
+
const remaining = [];
|
|
578
|
+
for (const b of remainingBase) {
|
|
579
|
+
const newPath = renameMap.get(b.source.file);
|
|
580
|
+
if (newPath === void 0) {
|
|
581
|
+
remaining.push(b);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const expectedId = rewriteIdFile(b.id, b.source.file, newPath);
|
|
585
|
+
const h = headById.get(expectedId);
|
|
586
|
+
if (h === void 0 || usedHead.has(h.id)) {
|
|
587
|
+
remaining.push(b);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
matched.push({
|
|
591
|
+
base: b,
|
|
592
|
+
head: h,
|
|
593
|
+
rationale: "git-rename"
|
|
594
|
+
});
|
|
595
|
+
usedHead.add(h.id);
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
matched,
|
|
599
|
+
remainingBase: remaining,
|
|
600
|
+
remainingHead: remainingHead.filter((h) => !usedHead.has(h.id))
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function rewriteIdFile(id, oldPath, newPath) {
|
|
604
|
+
const colon = id.indexOf(":");
|
|
605
|
+
const hash = id.indexOf("#");
|
|
606
|
+
if (colon < 0 || hash < 0 || hash < colon) return id;
|
|
607
|
+
const language = id.slice(0, colon);
|
|
608
|
+
if (id.slice(colon + 1, hash) !== oldPath) return id;
|
|
609
|
+
return `${language}:${newPath}${id.slice(hash)}`;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* §3.3 — group base Symbols by `fingerprint.logic` and match head Symbols whose logic
|
|
613
|
+
* fingerprint is in the map. Two disambiguation branches:
|
|
614
|
+
* - single candidate → paired with `logic-fingerprint`
|
|
615
|
+
* - multiple candidates → best `nameSimilarity` wins if ≥ 0.85; otherwise leave both
|
|
616
|
+
* sides in remaining for stage 4 to re-evaluate
|
|
617
|
+
*
|
|
618
|
+
* Dropped symbols are excluded (§3.3 tail) — their logic fingerprint is the sentinel
|
|
619
|
+
* `"000000000000"` and would collide with every other dropped Symbol in the workspace.
|
|
620
|
+
* They flow to the stage-4.5 weak matcher instead.
|
|
621
|
+
*/
|
|
622
|
+
function matchStageLogicFingerprint(remainingBase, remainingHead) {
|
|
623
|
+
const logicMap = /* @__PURE__ */ new Map();
|
|
624
|
+
const carryBase = [];
|
|
625
|
+
for (const b of remainingBase) {
|
|
626
|
+
if (b.dropped || b.fingerprint.logic === ZERO_LOGIC_FP) {
|
|
627
|
+
carryBase.push(b);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const bucket = logicMap.get(b.fingerprint.logic) ?? [];
|
|
631
|
+
bucket.push(b);
|
|
632
|
+
logicMap.set(b.fingerprint.logic, bucket);
|
|
633
|
+
}
|
|
634
|
+
const matched = [];
|
|
635
|
+
const carryHead = [];
|
|
636
|
+
const usedBaseIds = /* @__PURE__ */ new Set();
|
|
637
|
+
for (const h of remainingHead) {
|
|
638
|
+
if (h.dropped || h.fingerprint.logic === ZERO_LOGIC_FP) {
|
|
639
|
+
carryHead.push(h);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const candidates = logicMap.get(h.fingerprint.logic);
|
|
643
|
+
if (candidates === void 0 || candidates.length === 0) {
|
|
644
|
+
carryHead.push(h);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (candidates.length === 1) {
|
|
648
|
+
const only = candidates[0];
|
|
649
|
+
if (only === void 0) continue;
|
|
650
|
+
matched.push({
|
|
651
|
+
base: only,
|
|
652
|
+
head: h,
|
|
653
|
+
rationale: "logic-fingerprint"
|
|
654
|
+
});
|
|
655
|
+
usedBaseIds.add(only.id);
|
|
656
|
+
logicMap.delete(h.fingerprint.logic);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
let best = null;
|
|
660
|
+
for (const c of candidates) {
|
|
661
|
+
const score = nameSimilarity(c.name, h.name);
|
|
662
|
+
if (best === null || score > best.score) best = {
|
|
663
|
+
symbol: c,
|
|
664
|
+
score
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
if (best !== null && best.score >= .85) {
|
|
668
|
+
matched.push({
|
|
669
|
+
base: best.symbol,
|
|
670
|
+
head: h,
|
|
671
|
+
rationale: "logic-fingerprint+name-disambiguation"
|
|
672
|
+
});
|
|
673
|
+
usedBaseIds.add(best.symbol.id);
|
|
674
|
+
const remainingBucket = candidates.filter((c) => c.id !== best?.symbol.id);
|
|
675
|
+
if (remainingBucket.length === 0) logicMap.delete(h.fingerprint.logic);
|
|
676
|
+
else logicMap.set(h.fingerprint.logic, remainingBucket);
|
|
677
|
+
} else carryHead.push(h);
|
|
678
|
+
}
|
|
679
|
+
const finalBase = [...carryBase];
|
|
680
|
+
for (const bucket of logicMap.values()) for (const b of bucket) if (!usedBaseIds.has(b.id)) finalBase.push(b);
|
|
681
|
+
return {
|
|
682
|
+
matched,
|
|
683
|
+
remainingBase: finalBase,
|
|
684
|
+
remainingHead: carryHead
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* §3.4 — name + signature similarity with `(kind, signatureNullness)` bucket pre-filter.
|
|
689
|
+
* `signatureNullness` distinguishes symbols with no signature (`interface`, `type`,
|
|
690
|
+
* `class`) from callable ones so that a class body is never paired against a function.
|
|
691
|
+
*
|
|
692
|
+
* The composite score:
|
|
693
|
+
* 0.5 * nameSimilarity + 0.3 * signatureSimilarity + 0.2 * ownerSimilarity
|
|
694
|
+
*
|
|
695
|
+
* §3.4.3 threshold table:
|
|
696
|
+
* - 1-token name → 1.0 (unreachable → never paired; false-positive shield)
|
|
697
|
+
* - 2-token name → 0.95 (strict, avoids `getUser` vs `getUsers`)
|
|
698
|
+
* - default → 0.85
|
|
699
|
+
*
|
|
700
|
+
* §3.4.3 tail — if both sides are signature-less, we skip pairing entirely because
|
|
701
|
+
* signatureSimilarity always returns 1.0 for `null + null` and would flood the bucket with
|
|
702
|
+
* false pairings.
|
|
703
|
+
*/
|
|
704
|
+
function matchStageNameSignature(remainingBase, remainingHead) {
|
|
705
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
706
|
+
for (const b of remainingBase) {
|
|
707
|
+
if (b.dropped) continue;
|
|
708
|
+
const key = bucketKey(b);
|
|
709
|
+
const bucket = buckets.get(key) ?? [];
|
|
710
|
+
bucket.push(b);
|
|
711
|
+
buckets.set(key, bucket);
|
|
712
|
+
}
|
|
713
|
+
const matched = [];
|
|
714
|
+
const remainingHeadOut = [];
|
|
715
|
+
const usedBaseIds = /* @__PURE__ */ new Set();
|
|
716
|
+
for (const h of remainingHead) {
|
|
717
|
+
if (h.dropped) {
|
|
718
|
+
remainingHeadOut.push(h);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const key = bucketKey(h);
|
|
722
|
+
const bucket = buckets.get(key);
|
|
723
|
+
if (bucket === void 0 || bucket.length === 0) {
|
|
724
|
+
remainingHeadOut.push(h);
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if ((h.signature === null || h.signature === void 0) && bucket.every((c) => c.signature === null || c.signature === void 0)) {
|
|
728
|
+
remainingHeadOut.push(h);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
let best = null;
|
|
732
|
+
for (const c of bucket) {
|
|
733
|
+
const score = .5 * nameSimilarity(c.name, h.name) + .3 * signatureSimilarity(c.signature ?? null, h.signature ?? null) + .2 * ownerSimilarity(c.name, h.name);
|
|
734
|
+
if (best === null || score > best.score) best = {
|
|
735
|
+
symbol: c,
|
|
736
|
+
score
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
const threshold = thresholdFor(h.name);
|
|
740
|
+
if (best !== null && best.score >= threshold) {
|
|
741
|
+
matched.push({
|
|
742
|
+
base: best.symbol,
|
|
743
|
+
head: h,
|
|
744
|
+
rationale: "name-signature"
|
|
745
|
+
});
|
|
746
|
+
usedBaseIds.add(best.symbol.id);
|
|
747
|
+
buckets.set(key, bucket.filter((c) => c.id !== best?.symbol.id));
|
|
748
|
+
} else remainingHeadOut.push(h);
|
|
749
|
+
}
|
|
750
|
+
const remainingBaseOut = [];
|
|
751
|
+
for (const b of remainingBase) {
|
|
752
|
+
if (b.dropped) {
|
|
753
|
+
remainingBaseOut.push(b);
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
if (!usedBaseIds.has(b.id)) remainingBaseOut.push(b);
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
matched,
|
|
760
|
+
remainingBase: remainingBaseOut,
|
|
761
|
+
remainingHead: remainingHeadOut
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
function bucketKey(s) {
|
|
765
|
+
const sig = s.signature === null || s.signature === void 0 ? "no-sig" : "has-sig";
|
|
766
|
+
return `${s.kind}::${sig}`;
|
|
767
|
+
}
|
|
768
|
+
function thresholdFor(qname) {
|
|
769
|
+
const tokens = tokenizeName(lastSegment(qname)).length;
|
|
770
|
+
if (tokens <= 1) return 1;
|
|
771
|
+
if (tokens === 2) return .95;
|
|
772
|
+
return .85;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* §3.4.5 — dropped-only weak matcher. For dropped Symbols the fingerprint is zeroed and
|
|
776
|
+
* name/signature are the only remaining signals. Score is a coarse "did the last name
|
|
777
|
+
* segment or file basename survive?" — if either half matches we pair (threshold 0.5).
|
|
778
|
+
*
|
|
779
|
+
* This is deliberately lax; diff-algorithm.md §3.4.5 already accepts that dropped Symbols live
|
|
780
|
+
* outside the main IR review surface, so occasional false pairings only affect the
|
|
781
|
+
* "Drop-rule variation" fold-out section in the Markdown projection.
|
|
782
|
+
*/
|
|
783
|
+
function matchStageDroppedWeak(remainingBase, remainingHead) {
|
|
784
|
+
const droppedBase = remainingBase.filter((s) => s.dropped);
|
|
785
|
+
const nonDroppedBase = remainingBase.filter((s) => !s.dropped);
|
|
786
|
+
const droppedHead = remainingHead.filter((s) => s.dropped);
|
|
787
|
+
const nonDroppedHead = remainingHead.filter((s) => !s.dropped);
|
|
788
|
+
const matched = [];
|
|
789
|
+
const usedBaseIds = /* @__PURE__ */ new Set();
|
|
790
|
+
const carryHead = [];
|
|
791
|
+
for (const h of droppedHead) {
|
|
792
|
+
let best = null;
|
|
793
|
+
for (const b of droppedBase) {
|
|
794
|
+
if (b.kind !== h.kind) continue;
|
|
795
|
+
if (usedBaseIds.has(b.id)) continue;
|
|
796
|
+
const nameHit = lastSegment(b.name) === lastSegment(h.name) ? 1 : 0;
|
|
797
|
+
const fileHit = basename(b.source.file) === basename(h.source.file) ? 1 : 0;
|
|
798
|
+
const score = .5 * nameHit + .5 * fileHit;
|
|
799
|
+
if (best === null || score > best.score) best = {
|
|
800
|
+
symbol: b,
|
|
801
|
+
score
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
if (best !== null && best.score >= .5) {
|
|
805
|
+
matched.push({
|
|
806
|
+
base: best.symbol,
|
|
807
|
+
head: h,
|
|
808
|
+
rationale: "dropped-weak-match"
|
|
809
|
+
});
|
|
810
|
+
usedBaseIds.add(best.symbol.id);
|
|
811
|
+
} else carryHead.push(h);
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
matched,
|
|
815
|
+
remainingBase: [...nonDroppedBase, ...droppedBase.filter((b) => !usedBaseIds.has(b.id))],
|
|
816
|
+
remainingHead: [...nonDroppedHead, ...carryHead]
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
function basename(path) {
|
|
820
|
+
const slash = path.lastIndexOf("/");
|
|
821
|
+
return slash >= 0 ? path.slice(slash + 1) : path;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/status.ts
|
|
826
|
+
/**
|
|
827
|
+
* §4 — determine which of the five statuses a matched pair falls into. `dropped-toggled`
|
|
828
|
+
* has absolute priority: whenever `dropped` differs between base and head, every other
|
|
829
|
+
* signal is ignored because the underlying fingerprint change is a consequence of the
|
|
830
|
+
* drop-rule flip, not a genuine code edit (§4.1 rationale).
|
|
831
|
+
*
|
|
832
|
+
* "pathChanged" catches both physical file relocation AND in-file rename: any Symbol id
|
|
833
|
+
* mismatch between base and head means the identifier moved, and DF9 ("method 名 rename
|
|
834
|
+
* (同 file、同 logic) → moved") relies on this. Stage-1 pairs cannot trigger the id branch
|
|
835
|
+
* (they match by exact id), so this only affects stage-2+ pairings where the pair was
|
|
836
|
+
* discovered via fingerprint or name/signature similarity.
|
|
837
|
+
*/
|
|
838
|
+
function classifyStatus(base, head) {
|
|
839
|
+
if (base.dropped !== head.dropped) return "dropped-toggled";
|
|
840
|
+
const pathChanged = base.source.file !== head.source.file || base.id !== head.id;
|
|
841
|
+
const fingerprintChanged = base.fingerprint.api !== head.fingerprint.api || base.fingerprint.logic !== head.fingerprint.logic || base.fingerprint.syntax !== head.fingerprint.syntax;
|
|
842
|
+
if (pathChanged && fingerprintChanged) return "moved+changed";
|
|
843
|
+
if (pathChanged) return "moved";
|
|
844
|
+
if (fingerprintChanged) return "changed";
|
|
845
|
+
return "unchanged";
|
|
846
|
+
}
|
|
847
|
+
function dropDirection(head) {
|
|
848
|
+
return head.dropped ? "to-dropped" : "to-kept";
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
//#endregion
|
|
852
|
+
//#region src/diff.ts
|
|
853
|
+
const DIFF_SCHEMA = "https://aburi.dev/schema/aburi.diff.v1.json";
|
|
854
|
+
const DEFAULT_GENERATOR = {
|
|
855
|
+
name: "aburi",
|
|
856
|
+
version: "0.0.0"
|
|
857
|
+
};
|
|
858
|
+
/**
|
|
859
|
+
* Top-level entry: run the 5-stage matcher, classify each pair, produce array deltas,
|
|
860
|
+
* fold in Component / Dependency diffs, and assemble the `aburi.diff.v1` JSON projection.
|
|
861
|
+
* The function is pure; write-to-disk is delegated to `writeCanonicalDiff` so callers can
|
|
862
|
+
* pipe the result through additional steps (Markdown projection, `--fail-on` gate) before
|
|
863
|
+
* serialisation.
|
|
864
|
+
*/
|
|
865
|
+
function buildDiff(input) {
|
|
866
|
+
assertIRShape(input.baseIR, "baseIR");
|
|
867
|
+
assertIRShape(input.headIR, "headIR");
|
|
868
|
+
ensureSchemasAgree(input.baseIR, input.headIR);
|
|
869
|
+
const stage1 = matchStageId(input.baseIR.symbols, input.headIR.symbols);
|
|
870
|
+
const stage2 = matchStageGitRename(stage1.remainingBase, stage1.remainingHead, input.gitRenames ?? null);
|
|
871
|
+
const stage3 = matchStageLogicFingerprint(stage2.remainingBase, stage2.remainingHead);
|
|
872
|
+
const stage4 = matchStageNameSignature(stage3.remainingBase, stage3.remainingHead);
|
|
873
|
+
const stage4_5 = matchStageDroppedWeak(stage4.remainingBase, stage4.remainingHead);
|
|
874
|
+
const pairs = [
|
|
875
|
+
...stage1.matched,
|
|
876
|
+
...stage2.matched,
|
|
877
|
+
...stage3.matched,
|
|
878
|
+
...stage4.matched,
|
|
879
|
+
...stage4_5.matched
|
|
880
|
+
];
|
|
881
|
+
const summary = {
|
|
882
|
+
added: 0,
|
|
883
|
+
removed: 0,
|
|
884
|
+
moved: 0,
|
|
885
|
+
movedChanged: 0,
|
|
886
|
+
changed: 0,
|
|
887
|
+
droppedToggled: 0,
|
|
888
|
+
unchanged: 0,
|
|
889
|
+
droppedAdded: 0,
|
|
890
|
+
droppedRemoved: 0,
|
|
891
|
+
componentsAdded: 0,
|
|
892
|
+
componentsRemoved: 0,
|
|
893
|
+
componentsChanged: 0,
|
|
894
|
+
depsAdded: 0,
|
|
895
|
+
depsRemoved: 0
|
|
896
|
+
};
|
|
897
|
+
const symbols = [];
|
|
898
|
+
for (const pair of pairs) {
|
|
899
|
+
const status = classifyStatus(pair.base, pair.head);
|
|
900
|
+
if (status === "unchanged") {
|
|
901
|
+
summary.unchanged++;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (status === "dropped-toggled") {
|
|
905
|
+
summary.droppedToggled++;
|
|
906
|
+
symbols.push({
|
|
907
|
+
status: "dropped-toggled",
|
|
908
|
+
before: pair.base,
|
|
909
|
+
after: pair.head,
|
|
910
|
+
direction: dropDirection(pair.head)
|
|
911
|
+
});
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (status === "moved") {
|
|
915
|
+
summary.moved++;
|
|
916
|
+
symbols.push({
|
|
917
|
+
status: "moved",
|
|
918
|
+
before: pair.base,
|
|
919
|
+
after: pair.head,
|
|
920
|
+
rationale: pair.rationale
|
|
921
|
+
});
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (status === "changed") {
|
|
925
|
+
summary.changed++;
|
|
926
|
+
symbols.push({
|
|
927
|
+
status: "changed",
|
|
928
|
+
before: pair.base,
|
|
929
|
+
after: pair.head,
|
|
930
|
+
delta: computeSymbolDelta(pair.base, pair.head, input.delta ?? {})
|
|
931
|
+
});
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
summary.movedChanged++;
|
|
935
|
+
symbols.push({
|
|
936
|
+
status: "moved+changed",
|
|
937
|
+
before: pair.base,
|
|
938
|
+
after: pair.head,
|
|
939
|
+
rationale: pair.rationale,
|
|
940
|
+
delta: computeSymbolDelta(pair.base, pair.head, input.delta ?? {})
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
const finalRemainingHead = stage4_5.remainingHead;
|
|
944
|
+
const finalRemainingBase = stage4_5.remainingBase;
|
|
945
|
+
for (const h of finalRemainingHead) if (h.dropped) summary.droppedAdded++;
|
|
946
|
+
else {
|
|
947
|
+
summary.added++;
|
|
948
|
+
symbols.push({
|
|
949
|
+
status: "added",
|
|
950
|
+
symbol: h
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
for (const b of finalRemainingBase) if (b.dropped) summary.droppedRemoved++;
|
|
954
|
+
else {
|
|
955
|
+
summary.removed++;
|
|
956
|
+
symbols.push({
|
|
957
|
+
status: "removed",
|
|
958
|
+
symbol: b
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
const components = diffComponents(input.baseIR.components, input.headIR.components);
|
|
962
|
+
summary.componentsAdded = components.added.length;
|
|
963
|
+
summary.componentsRemoved = components.removed.length;
|
|
964
|
+
summary.componentsChanged = components.changed.length;
|
|
965
|
+
const dependencies = diffDependencies(input.baseIR.dependencies, input.headIR.dependencies);
|
|
966
|
+
summary.depsAdded = dependencies.added.length;
|
|
967
|
+
summary.depsRemoved = dependencies.removed.length;
|
|
968
|
+
symbols.sort(compareSymbolChange);
|
|
969
|
+
return {
|
|
970
|
+
$schema: DIFF_SCHEMA,
|
|
971
|
+
generator: input.generator ?? DEFAULT_GENERATOR,
|
|
972
|
+
base: input.base,
|
|
973
|
+
head: input.head,
|
|
974
|
+
summary,
|
|
975
|
+
symbols,
|
|
976
|
+
components,
|
|
977
|
+
dependencies
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
/** §9.1 — refuse to diff across schema versions. */
|
|
981
|
+
function ensureSchemasAgree(base, head) {
|
|
982
|
+
if (base.$schema !== head.$schema) throw new DiffError(`Base IR schema "${base.$schema}" does not match head IR schema "${head.$schema}"; a diff across schema versions is not supported.`, {
|
|
983
|
+
code: "schema-mismatch",
|
|
984
|
+
value: base.$schema
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Top-level shape check. Refuses to enter the 5-stage matcher when a caller hands in an
|
|
989
|
+
* IR-shaped object that is missing one of the collections the matcher requires as an
|
|
990
|
+
* array. Without this, a malformed `baseIR = { symbols: undefined, ... }` would crash
|
|
991
|
+
* deep inside `matchStageId` with `TypeError: undefined is not iterable`, leaving the
|
|
992
|
+
* root cause obscured. Not a full schema validation — just an entry-point smoke test.
|
|
993
|
+
*/
|
|
994
|
+
function assertIRShape(ir, name) {
|
|
995
|
+
if (ir === null || typeof ir !== "object") throw new DiffError(`${name} must be an IR object; got ${typeof ir}.`, {
|
|
996
|
+
code: "ir-shape-invalid",
|
|
997
|
+
value: name
|
|
998
|
+
});
|
|
999
|
+
if (typeof ir.$schema !== "string" || ir.$schema.length === 0) throw new DiffError(`${name}.$schema must be a non-empty schema URL.`, {
|
|
1000
|
+
code: "ir-shape-invalid",
|
|
1001
|
+
value: `${name}.$schema`
|
|
1002
|
+
});
|
|
1003
|
+
for (const field of [
|
|
1004
|
+
"symbols",
|
|
1005
|
+
"components",
|
|
1006
|
+
"dependencies"
|
|
1007
|
+
]) if (!Array.isArray(ir[field])) throw new DiffError(`${name}.${field} must be an array.`, {
|
|
1008
|
+
code: "ir-shape-invalid",
|
|
1009
|
+
value: `${name}.${field}`
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Deterministic ordering of the `symbols[]` output:
|
|
1014
|
+
* 1. by status (alphabetical) — pins added/changed/moved sections
|
|
1015
|
+
* 2. by the "reference" id (`after` for change/move/toggle, otherwise `symbol`)
|
|
1016
|
+
*
|
|
1017
|
+
* The result is stable byte-for-byte for equal inputs, matching the canonicalisation
|
|
1018
|
+
* guarantee the diff JSON must uphold when persisted to `out/diff.json`.
|
|
1019
|
+
*/
|
|
1020
|
+
function compareSymbolChange(a, b) {
|
|
1021
|
+
if (a.status !== b.status) return a.status < b.status ? -1 : 1;
|
|
1022
|
+
const idA = referenceId(a);
|
|
1023
|
+
const idB = referenceId(b);
|
|
1024
|
+
return idA < idB ? -1 : idA > idB ? 1 : 0;
|
|
1025
|
+
}
|
|
1026
|
+
function referenceId(change) {
|
|
1027
|
+
if (change.status === "added" || change.status === "removed") return change.symbol.id;
|
|
1028
|
+
return change.after.id;
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Byte-deterministic serialiser for a DiffResult. Delegates to `@aburi/core`
|
|
1032
|
+
* `serializeCanonical` so the sort order, NFC normalisation, and codepoint key sort are
|
|
1033
|
+
* shared with the IR side.
|
|
1034
|
+
*/
|
|
1035
|
+
function writeCanonicalDiff(diff, options = {}) {
|
|
1036
|
+
return serializeCanonical(diff, options);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
//#endregion
|
|
1040
|
+
export { DEFAULT_LINE_FUZZ, DiffError, MAX_LINE_FUZZ, MIN_LINE_FUZZ, buildDiff, classifyStatus, computeSymbolDelta, diffComponents, diffDependencies, dropDirection, jaccard, jaccardTokens, lastSegment, matchStageDroppedWeak, matchStageGitRename, matchStageId, matchStageLogicFingerprint, matchStageNameSignature, nameSimilarity, ownerSimilarity, signatureSimilarity, tokenizeName, writeCanonicalDiff };
|
|
1041
|
+
//# sourceMappingURL=index.mjs.map
|