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