@aburi/diff 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,2180 +1 @@
1
- import { RESERVED_LANGUAGE_IDS, computeWeaklyConnectedComponents, reconstructCallEdgesFromIR, serializeCanonical, trySymbolId } from "@aburi/core";
2
-
3
- //#region src/components.ts
4
- /**
5
- * `docs/design/diff-algorithm.md` §6.1 — Component diff. Assumes `components[].id` is unique on each side (ir-schema.md §14
6
- * #2) and does not check it: `buildDiff` establishes that before calling, and a caller
7
- * reaching this export directly owns the obligation, because the lookup map here is
8
- * last-write-wins and a repeat is lost rather than reported.
9
- *
10
- * Component identity is `id`; when a Component is present in both
11
- * base and head with the same id, the three delta booleans describe which axes actually
12
- * moved (roots reshuffle, public API globs, or framework hint list). `modified` deltas
13
- * are intentionally absent from this diff (diff-algorithm.md §5.2.3): fields are reported as before/after
14
- * pairs via the `changed[]` entries so consumers can render them without stringifying
15
- * arrays.
16
- */
17
- function diffComponents(base, head) {
18
- const baseById = /* @__PURE__ */ new Map();
19
- for (const c of base) baseById.set(c.id, c);
20
- const headById = /* @__PURE__ */ new Map();
21
- for (const c of head) headById.set(c.id, c);
22
- const added = [];
23
- const removed = [];
24
- const changed = [];
25
- for (const [id, headComp] of headById) {
26
- const baseComp = baseById.get(id);
27
- if (baseComp === void 0) {
28
- added.push(headComp);
29
- continue;
30
- }
31
- const rootsChanged = !stringArraysEqual(baseComp.roots, headComp.roots);
32
- const publicApiChanged = !stringArraysEqual(baseComp.publicApi ?? [], headComp.publicApi ?? []);
33
- const frameworksChanged = !stringArraysEqual(baseComp.frameworks ?? [], headComp.frameworks ?? []);
34
- if (rootsChanged || publicApiChanged || frameworksChanged) changed.push({
35
- before: baseComp,
36
- after: headComp,
37
- delta: {
38
- rootsChanged,
39
- publicApiChanged,
40
- frameworksChanged
41
- }
42
- });
43
- }
44
- for (const [id, baseComp] of baseById) if (!headById.has(id)) removed.push(baseComp);
45
- sortById(added);
46
- sortById(removed);
47
- changed.sort((a, b) => a.after.id < b.after.id ? -1 : a.after.id > b.after.id ? 1 : 0);
48
- return {
49
- added,
50
- removed,
51
- changed
52
- };
53
- }
54
- /**
55
- * Build a side view from a document. The only construction site there is.
56
- *
57
- * `buildDiff` reads `lostFiles` for its own Symbol classification too, from this same object,
58
- * so "a Symbol reported unknown and the edges it took with it cannot disagree about which file
59
- * went missing" is a property of the wiring rather than a claim in a comment.
60
- *
61
- * Exported because `DependencySideView` is public and `diffDependencies` requires one: without
62
- * a factory a caller would have to reproduce the `symbols[].source.file` keying and the
63
- * `stats.skippedFiles` read, and the likeliest outcome of that is a caller who gets it wrong.
64
- */
65
- function dependencySideView(ir) {
66
- const symbolFiles = /* @__PURE__ */ new Map();
67
- for (const symbol of ir.symbols) symbolFiles.set(symbol.id, symbol.source.file);
68
- const lostFiles = /* @__PURE__ */ new Map();
69
- for (const file of ir.stats.skippedFiles ?? []) lostFiles.set(file.path, file.reason);
70
- return {
71
- symbolFiles,
72
- lostFiles
73
- };
74
- }
75
- /**
76
- * `docs/design/diff-algorithm.md` §6.2 — Dependency diff. Identity is the composite
77
- * `(from, to, via)` triple; direction and effect changes are surfaced as an added + removed
78
- * pair so `modified` is not part of the schema (§6.2 tail). Uniqueness of that triple is the caller's obligation on the same
79
- * terms as `diffComponents` above — a repeat here is indistinguishable in the output from
80
- * the flip it encodes.
81
- *
82
- * `sides` is what separates a deletion from a loss (`docs/design/diff-algorithm.md` §6.2.1).
83
- * It is required rather than optional: omitting it would classify every edge into a lost file
84
- * as a deletion again — silently, and while still writing `unknown: []`, which this schema
85
- * defines as "nothing was unknown" rather than "nobody looked". A caller with no skip list to
86
- * offer says so by passing a side view whose `lostFiles` is empty, which is the honest spelling
87
- * of an IR written before `stats.skippedFiles` existed.
88
- *
89
- * The return type declares `unknown` present, where the schema leaves it optional for documents
90
- * that predate the field. A reader may find it missing; this function always writes it.
91
- */
92
- function diffDependencies(base, head, sides) {
93
- const baseKeys = /* @__PURE__ */ new Map();
94
- for (const d of base) baseKeys.set(dependencyKey(d), d);
95
- const headKeys = /* @__PURE__ */ new Map();
96
- for (const d of head) headKeys.set(dependencyKey(d), d);
97
- const added = [];
98
- const removed = [];
99
- const unknown = [];
100
- for (const [key, dep] of headKeys) {
101
- const b = baseKeys.get(key);
102
- if (b === void 0) {
103
- const lostFiles = endpointsLostBy(dep, sides.head, sides.base);
104
- if (lostFiles.length > 0) unknown.push({
105
- dependency: dep,
106
- absentFrom: "base",
107
- lostFiles
108
- });
109
- else added.push(dep);
110
- continue;
111
- }
112
- if (b.direction !== dep.direction || (b.effect ?? null) !== (dep.effect ?? null)) {
113
- removed.push(b);
114
- added.push(dep);
115
- }
116
- }
117
- for (const [key, dep] of baseKeys) {
118
- if (headKeys.has(key)) continue;
119
- const lostFiles = endpointsLostBy(dep, sides.base, sides.head);
120
- if (lostFiles.length > 0) unknown.push({
121
- dependency: dep,
122
- absentFrom: "head",
123
- lostFiles
124
- });
125
- else removed.push(dep);
126
- }
127
- added.sort(compareDependencies);
128
- removed.sort(compareDependencies);
129
- unknown.sort((a, b) => compareDependencies(a.dependency, b.dependency));
130
- return {
131
- added,
132
- removed,
133
- unknown
134
- };
135
- }
136
- /**
137
- * The endpoint files `absent` never analysed, read through `holder` because that is the
138
- * document the edge — and therefore the Symbol behind each endpoint — actually comes from.
139
- *
140
- * Both endpoints are checked: an edge dies when *either* end's file goes, including one whose
141
- * other end survived, and that half is the easiest to miss because the surviving Symbol is
142
- * right there in both documents.
143
- *
144
- * Deduped and sorted by path, so an intra-file edge collapses to the one file it lost.
145
- */
146
- function endpointsLostBy(dep, holder, absent) {
147
- const byPath = /* @__PURE__ */ new Map();
148
- for (const endpoint of [dep.from, dep.to]) {
149
- const file = holder.symbolFiles.get(endpoint);
150
- if (file === void 0) continue;
151
- const reason = absent.lostFiles.get(file);
152
- if (reason === void 0) continue;
153
- byPath.set(file, reason);
154
- }
155
- return [...byPath.entries()].map(([path, reason]) => ({
156
- path,
157
- reason
158
- })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
159
- }
160
- /**
161
- * `docs/design/diff-algorithm.md` §6.2 — the fields Dependency identity is made of, in key order, and the join that turns
162
- * them into one. Both halves are exported so the entry-point uniqueness check keys on
163
- * exactly what this file keys on — a check with its own notion of identity would let through
164
- * the duplicates that actually collide here, and a check that shared only the join would
165
- * still be free to disagree about which fields identity is made of.
166
- *
167
- * Core's invariant #13 joins the same triple with a different separator. The two agree for
168
- * every endpoint that satisfies the id grammars of ir-schema.md §3.1 and §4, which is every
169
- * Document the integrity checker accepts; they could in principle disagree for one that does
170
- * not, and `buildDiff` runs no grammar check on the IRs it is handed.
171
- */
172
- const DEPENDENCY_IDENTITY_FIELDS = [
173
- "from",
174
- "to",
175
- "via"
176
- ];
177
- function dependencyIdentity(parts) {
178
- return parts.join("::");
179
- }
180
- function dependencyKey(d) {
181
- return dependencyIdentity(DEPENDENCY_IDENTITY_FIELDS.map((field) => d[field]));
182
- }
183
- function compareDependencies(a, b) {
184
- const keyA = dependencyKey(a);
185
- const keyB = dependencyKey(b);
186
- return keyA < keyB ? -1 : keyA > keyB ? 1 : 0;
187
- }
188
- function stringArraysEqual(a, b) {
189
- if (a.length !== b.length) return false;
190
- for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
191
- return true;
192
- }
193
- function sortById(items) {
194
- items.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
195
- }
196
-
197
- //#endregion
198
- //#region src/errors.ts
199
- var DiffError = class extends Error {
200
- code;
201
- value;
202
- constructor(message, detail, options) {
203
- super(message, options);
204
- this.name = "DiffError";
205
- this.code = detail.code;
206
- this.value = detail.value;
207
- }
208
- };
209
-
210
- //#endregion
211
- //#region src/delta.ts
212
- const DEFAULT_LINE_FUZZ = 2;
213
- const MAX_LINE_FUZZ = 10;
214
- const MIN_LINE_FUZZ = 0;
215
- /**
216
- * §5 — Compute the full per-Symbol delta between two paired Symbols. The three axis
217
- * booleans (`apiChanged` / `logicChanged` / `syntaxChanged`) come from fingerprint
218
- * comparison; the array deltas (`rules` / `effects` / `calls` / `decorators`) come from
219
- * identity-preserving diff with configurable line fuzz.
220
- */
221
- function computeSymbolDelta(base, head, options = {}) {
222
- const fuzz = validateLineFuzz(options.lineFuzz ?? 2);
223
- const delta = {
224
- apiChanged: base.fingerprint.api !== head.fingerprint.api,
225
- logicChanged: base.fingerprint.logic !== head.fingerprint.logic,
226
- syntaxChanged: base.fingerprint.syntax !== head.fingerprint.syntax,
227
- componentChanged: (base.component ?? null) !== (head.component ?? null),
228
- visibilityChanged: base.visibility !== head.visibility
229
- };
230
- delta.rules = diffRules(base.rules, head.rules, fuzz);
231
- delta.effects = diffEffects(base.effects, head.effects);
232
- delta.calls = diffCalls(base.calls, head.calls, fuzz);
233
- delta.decorators = diffDecorators(base.decorators, head.decorators, fuzz);
234
- delta.signature = diffSignature(base.signature ?? null, head.signature ?? null);
235
- return delta;
236
- }
237
- /**
238
- * §5.2.1 range check. Loud on violation rather than silently clamping so a config typo
239
- * (`lineFuzz: 999`) or an upstream numeric bug (`NaN`, `Infinity`) surfaces at the diff
240
- * boundary instead of quietly rounding into a range that produces the wrong deltas.
241
- */
242
- function validateLineFuzz(value) {
243
- if (!Number.isFinite(value) || !Number.isInteger(value)) throw new DiffError(`config.diff.lineFuzz must be an integer in [${0}, ${10}]; got ${String(value)}.`, {
244
- code: "invalid-line-fuzz",
245
- value: String(value)
246
- });
247
- if (value < 0 || value > 10) throw new DiffError(`config.diff.lineFuzz must be within [${0}, ${10}]; got ${value}.`, {
248
- code: "invalid-line-fuzz",
249
- value: String(value)
250
- });
251
- return value;
252
- }
253
- /**
254
- * §5.2 — pair the two sides by identity key within ±`lineFuzz`, then classify each element
255
- * into `added` / `removed` / `modified`. `modified` fires only when a pairing holds and the
256
- * content differs, so a cosmetic line shift produces nothing.
257
- *
258
- * Several elements of one Symbol routinely share a key — two `guard` rules, two calls to one
259
- * target, two `@Get` — so which base element a head element takes is a real choice. Two
260
- * passes make it: first the elements whose key **and content** agree, then whatever is left.
261
- * An untouched element is therefore claimed by its own counterpart before an edited or
262
- * deleted neighbour can take it, and the remainder pairs by proximity — where a genuine edit
263
- * lands. Taking the first key hit instead reported a deleted `guard@1` by removing its
264
- * untouched `guard@3` neighbour *and* listing that same neighbour as modified.
265
- *
266
- * Each pass is an order-preserving assignment rather than a per-element search, because a
267
- * greedy pass takes a pairing that leaves a better set unreachable. Two identical guards
268
- * shifted down two lines are the smallest case: the first head element is nearest to the
269
- * *second* base element, and claiming it strands the other head outside the window, so a
270
- * block that moved intact comes back as an `added` and a `removed`.
271
- */
272
- function differentiate(base, head, isEqual, lineFuzz) {
273
- const freeBase = new Set(base.map((_, index) => index));
274
- const freeHead = new Set(head.map((_, index) => index));
275
- const partnerOf = /* @__PURE__ */ new Map();
276
- for (const contentMustAgree of [true, false]) {
277
- const admits = (b, h) => b.key === h.key && Math.abs(b.line - h.line) <= lineFuzz && (!contentMustAgree || isEqual(b.item, h.item));
278
- for (const [baseIndex, headIndex] of assignInOrder(base, head, freeBase, freeHead, admits)) {
279
- freeBase.delete(baseIndex);
280
- freeHead.delete(headIndex);
281
- const counterpart = base[baseIndex];
282
- if (counterpart !== void 0) partnerOf.set(headIndex, counterpart);
283
- }
284
- }
285
- const added = [];
286
- const modified = [];
287
- for (const [index, h] of head.entries()) {
288
- const counterpart = partnerOf.get(index);
289
- if (counterpart === void 0) added.push(h.item);
290
- else if (!isEqual(counterpart.item, h.item)) modified.push(h.item);
291
- }
292
- return {
293
- added,
294
- removed: base.filter((_, index) => freeBase.has(index)).map((b) => b.item),
295
- modified
296
- };
297
- }
298
- const NO_PAIRINGS = {
299
- pairs: 0,
300
- distance: 0
301
- };
302
- /** `a` is preferred over `b`: more pairings wins, and among equals the tighter one does. */
303
- function better(a, b) {
304
- return a.pairs !== b.pairs ? a.pairs > b.pairs : a.distance < b.distance;
305
- }
306
- /**
307
- * The best set of non-crossing pairings between the still-free elements of `base` and `head`,
308
- * as `[baseIndex, headIndex]` in ascending order — most pairings first, then least total line
309
- * movement.
310
- *
311
- * Non-crossing is the whole content of the rule, and ir-schema §14 #11 is what licenses it:
312
- * these arrays are ordered by line, so `i < j` means element `i` sits above element `j` in the
313
- * file. Two pairings that cross would have an element move above one it was below, which is
314
- * not a line shift — it is a different element. Restricting to non-crossing sets also makes
315
- * the optimum reachable by a suffix recurrence rather than by a general assignment algorithm.
316
- *
317
- * Maximising the count before minimising distance is what stops a near pairing from being
318
- * taken at the cost of a far one that would otherwise have no partner at all.
319
- */
320
- function assignInOrder(base, head, freeBase, freeHead, admits) {
321
- const best = Array.from({ length: base.length + 1 }, () => Array.from({ length: head.length + 1 }, () => NO_PAIRINGS));
322
- const pairingAt = (i, j) => {
323
- const b = base[i];
324
- const h = head[j];
325
- if (b === void 0 || h === void 0) return null;
326
- if (!freeBase.has(i) || !freeHead.has(j) || !admits(b, h)) return null;
327
- const rest = best[i + 1]?.[j + 1] ?? NO_PAIRINGS;
328
- return {
329
- pairs: rest.pairs + 1,
330
- distance: rest.distance + Math.abs(b.line - h.line)
331
- };
332
- };
333
- for (let i = base.length - 1; i >= 0; i--) for (let j = head.length - 1; j >= 0; j--) {
334
- const skipBase = best[i + 1]?.[j] ?? NO_PAIRINGS;
335
- const skipHead = best[i]?.[j + 1] ?? NO_PAIRINGS;
336
- let winner = better(skipBase, skipHead) ? skipBase : skipHead;
337
- const paired = pairingAt(i, j);
338
- if (paired !== null && better(paired, winner)) winner = paired;
339
- const row = best[i];
340
- if (row !== void 0) row[j] = winner;
341
- }
342
- const chosen = [];
343
- let i = 0;
344
- let j = 0;
345
- while (i < base.length && j < head.length) {
346
- const here = best[i]?.[j] ?? NO_PAIRINGS;
347
- const paired = pairingAt(i, j);
348
- if (paired !== null && paired.pairs === here.pairs && paired.distance === here.distance) {
349
- chosen.push([i, j]);
350
- i++;
351
- j++;
352
- continue;
353
- }
354
- const skipBase = best[i + 1]?.[j] ?? NO_PAIRINGS;
355
- if (skipBase.pairs === here.pairs && skipBase.distance === here.distance) i++;
356
- else j++;
357
- }
358
- return chosen;
359
- }
360
- function diffRules(base, head, lineFuzz) {
361
- const mapper = (r) => ({
362
- item: r,
363
- key: r.type,
364
- line: r.line
365
- });
366
- return differentiate(base.map(mapper), head.map(mapper), rulesEqual, lineFuzz);
367
- }
368
- function rulesEqual(a, b) {
369
- 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);
370
- }
371
- function diffEffects(base, head) {
372
- const mapper = (e) => ({
373
- item: e,
374
- key: `${e.id}::${e.target}`,
375
- line: e.line ?? 0
376
- });
377
- return differentiate(base.map(mapper), head.map(mapper), effectsEqual, Number.POSITIVE_INFINITY);
378
- }
379
- function effectsEqual(a, b) {
380
- return a.id === b.id && a.target === b.target && a.plugin === b.plugin && a.confidence === b.confidence;
381
- }
382
- function diffCalls(base, head, lineFuzz) {
383
- const mapper = (c) => ({
384
- item: c,
385
- key: c.target,
386
- line: c.line
387
- });
388
- return differentiate(base.map(mapper), head.map(mapper), callsEqual, lineFuzz);
389
- }
390
- function callsEqual(a, b) {
391
- return a.target === b.target && (a.resolved ?? null) === (b.resolved ?? null);
392
- }
393
- /**
394
- * §5.2.2 — decorator diff. Identity is `name`; when the same-named decorator is present
395
- * on both sides within the line-fuzz window, the argument list determines whether the
396
- * entry is a modified pair or an implicit no-op.
397
- */
398
- function diffDecorators(base, head, lineFuzz) {
399
- const mapper = (d) => ({
400
- item: d,
401
- key: d.name,
402
- line: d.line
403
- });
404
- return differentiate(base.map(mapper), head.map(mapper), decoratorsEqual, lineFuzz);
405
- }
406
- function decoratorsEqual(a, b) {
407
- if (a.name !== b.name) return false;
408
- if (a.arguments.length !== b.arguments.length) return false;
409
- for (let i = 0; i < a.arguments.length; i++) if (a.arguments[i] !== b.arguments[i]) return false;
410
- return true;
411
- }
412
- /**
413
- * §5.3 — signature delta. Three cases:
414
- * - both sides `null` → `null` (nothing to compare)
415
- * - exactly one side `null` → the present side is emitted verbatim as
416
- * `added` (when only head has a signature) or `removed` (when only base has one);
417
- * the three axis booleans compare the missing side against `false` defaults
418
- * - both sides non-null → per-list sub-deltas:
419
- * - `inputs`: strict positional compare via `differentiate(..., lineFuzz: 0)` — index
420
- * used as the line key so ordered parameter lists match position-for-position
421
- * - `outputs`: positional compare (added/removed emitted when the arrays diverge at
422
- * an index), no `modified` category
423
- * - `throws`: unordered set diff — duplicates collapse per Set semantics
424
- * - `async` / `generator` / `typeParameters` change flags follow the raw fields
425
- */
426
- function diffSignature(base, head) {
427
- if (base === null && head === null) return null;
428
- if (base === null || head === null) {
429
- const empty = {
430
- added: [],
431
- removed: [],
432
- modified: []
433
- };
434
- const present = base ?? head;
435
- if (present === null) return null;
436
- const isBaseNull = base === null;
437
- return {
438
- inputs: isBaseNull ? {
439
- added: [...present.inputs],
440
- removed: [],
441
- modified: []
442
- } : {
443
- added: [],
444
- removed: [...present.inputs],
445
- modified: []
446
- },
447
- outputs: isBaseNull ? {
448
- added: [...present.outputs],
449
- removed: [],
450
- modified: []
451
- } : {
452
- added: [],
453
- removed: [...present.outputs],
454
- modified: []
455
- },
456
- throws: isBaseNull ? {
457
- added: [...present.throws],
458
- removed: [],
459
- modified: []
460
- } : {
461
- added: [],
462
- removed: [...present.throws],
463
- modified: []
464
- },
465
- asyncChanged: (base?.async ?? false) !== (head?.async ?? false),
466
- generatorChanged: (base?.generator ?? false) !== (head?.generator ?? false),
467
- typeParametersChanged: !arraysEqual(base?.typeParameters ?? empty.added.map(String), head?.typeParameters ?? empty.added.map(String))
468
- };
469
- }
470
- /**
471
- * Parameters are positional, so the index is part of the identity and the fuzz below is 0.
472
- * That makes each key unique within its list, so §5.2.0 never has a choice to make here and
473
- * both of its passes reduce to the same positional comparison. Dropping the index from the
474
- * key would make two same-named parameters ambiguous and hand the choice back to §5.2.0,
475
- * which is not what a positional list wants.
476
- */
477
- const inputMapper = (input, index) => ({
478
- item: input,
479
- key: `${index}:${input.name}`,
480
- line: index
481
- });
482
- return {
483
- inputs: differentiate(base.inputs.map(inputMapper), head.inputs.map(inputMapper), (a, b) => a.name === b.name && a.type === b.type, 0),
484
- outputs: diffStringList(base.outputs, head.outputs),
485
- throws: diffStringSet(base.throws, head.throws),
486
- asyncChanged: base.async !== head.async,
487
- generatorChanged: base.generator !== head.generator,
488
- typeParametersChanged: !arraysEqual(base.typeParameters, head.typeParameters)
489
- };
490
- }
491
- function diffStringList(base, head) {
492
- const added = [];
493
- const removed = [];
494
- const max = Math.max(base.length, head.length);
495
- for (let i = 0; i < max; i++) {
496
- const b = i < base.length ? base[i] : void 0;
497
- const h = i < head.length ? head[i] : void 0;
498
- if (b === void 0 && h !== void 0) added.push(h);
499
- else if (h === void 0 && b !== void 0) removed.push(b);
500
- else if (b !== h) {
501
- if (b !== void 0) removed.push(b);
502
- if (h !== void 0) added.push(h);
503
- }
504
- }
505
- return {
506
- added,
507
- removed,
508
- modified: []
509
- };
510
- }
511
- function diffStringSet(base, head) {
512
- const setB = new Set(base);
513
- const setH = new Set(head);
514
- const added = [];
515
- const removed = [];
516
- for (const h of setH) if (!setB.has(h)) added.push(h);
517
- for (const b of setB) if (!setH.has(b)) removed.push(b);
518
- return {
519
- added,
520
- removed,
521
- modified: []
522
- };
523
- }
524
- function arraysEqual(a, b) {
525
- if (a.length !== b.length) return false;
526
- for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
527
- return true;
528
- }
529
-
530
- //#endregion
531
- //#region src/signature.ts
532
- /**
533
- * §3.4.2 signatureSimilarity — averaged type-similarity across `inputs`, `outputs`, and
534
- * `throws`. Follows diff-algorithm.md §3.4.2:
535
- * - null + null → 1.0 (both symbols are signature-less; nothing to compare)
536
- * - null + non-null (either side) → 0.0
537
- * - both non-null: mean of three subscores
538
- *
539
- * Subscores:
540
- * - inputs: ordered type-string equality rate (`i-th type equal` counts as 1, else 0)
541
- * - outputs: same shape, but outputs is an array of type strings
542
- * - throws: unordered set intersection over union
543
- *
544
- * Empty subarrays on both sides count as 1.0 (they are trivially equal); on one side only
545
- * they count as 0.0 (asymmetric information).
546
- */
547
- function signatureSimilarity(base, head) {
548
- const baseSig = base ?? null;
549
- const headSig = head ?? null;
550
- if (baseSig === null && headSig === null) return 1;
551
- if (baseSig === null || headSig === null) return 0;
552
- const inputsScore = compareOrderedTypes(baseSig.inputs.map((i) => i.type), headSig.inputs.map((i) => i.type));
553
- const outputsScore = compareOrderedTypes(baseSig.outputs, headSig.outputs);
554
- const throwsScore = compareUnorderedSet(baseSig.throws, headSig.throws);
555
- return (inputsScore + outputsScore + throwsScore) / 3;
556
- }
557
- function compareOrderedTypes(a, b) {
558
- if (a.length === 0 && b.length === 0) return 1;
559
- if (a.length !== b.length) return 0;
560
- let matches = 0;
561
- for (let i = 0; i < a.length; i++) if (a[i] === b[i]) matches++;
562
- return matches / a.length;
563
- }
564
- function compareUnorderedSet(a, b) {
565
- if (a.length === 0 && b.length === 0) return 1;
566
- const setA = new Set(a);
567
- const setB = new Set(b);
568
- let intersection = 0;
569
- for (const t of setA) if (setB.has(t)) intersection++;
570
- const union = setA.size + setB.size - intersection;
571
- return union === 0 ? 1 : intersection / union;
572
- }
573
-
574
- //#endregion
575
- //#region src/similarity.ts
576
- /**
577
- * String similarity utilities dedicated to the diff engine. Kept in one file so the
578
- * name/signature/owner formulas are auditable side by side against diff-algorithm.md §3.4.
579
- */
580
- /**
581
- * Tokenise a qualified name into normalised lowercase word segments. Splits on:
582
- * - camelCase / PascalCase boundaries (`fooBar` → `foo`, `Bar`)
583
- * - snake_case underscores (`foo_bar` → `foo`, `bar`)
584
- * - dotted namespaces (`ns.Class` → `ns`, `Class`)
585
- * - `::` static-method separator (`Class::method` → `Class`, `method`)
586
- * - runs of digits (`get2Users` → `get`, `2`, `Users`)
587
- *
588
- * The result is de-duplicated so `InvoiceService.createInvoice` collapses to
589
- * `["invoice", "service", "create"]` — Jaccard similarity relies on set semantics, so
590
- * repeated tokens must not double-count.
591
- *
592
- * **The camel boundary is ASCII.** `isCamelBoundary` compares code points against `a`–`z`,
593
- * `A`–`Z` and `0`–`9`, so a name written in a script with no ASCII case boundary and no
594
- * separator comes back whole: `获取用户信息` and `ユーザー情報を取得する` are one token each,
595
- * and so is `получитьПользователя` — its camel hump does not register. A separator still
596
- * splits (`ユーザー.取得` gives two), and a mixed name splits on its ASCII half.
597
- *
598
- * That makes the token count a poor measure of how much such a name says, which matters
599
- * wherever a count is read as a proxy for that — see §3.4.3's admissibility rule.
600
- */
601
- function tokenizeName(input) {
602
- const seen = /* @__PURE__ */ new Set();
603
- const out = [];
604
- for (const raw of input.split(/[.:_\-\s/]+/)) {
605
- if (raw.length === 0) continue;
606
- for (const piece of splitCamel(raw)) {
607
- const norm = piece.toLowerCase();
608
- if (norm.length === 0 || seen.has(norm)) continue;
609
- seen.add(norm);
610
- out.push(norm);
611
- }
612
- }
613
- return out;
614
- }
615
- function splitCamel(word) {
616
- const chunks = [];
617
- let current = "";
618
- for (const ch of word) {
619
- if (current === "") {
620
- current = ch;
621
- continue;
622
- }
623
- if (isCamelBoundary(current.slice(-1), ch)) {
624
- chunks.push(current);
625
- current = ch;
626
- } else current += ch;
627
- }
628
- if (current.length > 0) chunks.push(current);
629
- return chunks;
630
- }
631
- function isCamelBoundary(prev, curr) {
632
- const prevLower = prev >= "a" && prev <= "z";
633
- const prevUpper = prev >= "A" && prev <= "Z";
634
- const prevDigit = prev >= "0" && prev <= "9";
635
- const currLower = curr >= "a" && curr <= "z";
636
- const currUpper = curr >= "A" && curr <= "Z";
637
- const currDigit = curr >= "0" && curr <= "9";
638
- if (prevLower && currUpper) return true;
639
- if ((prevLower || prevUpper) && currDigit) return true;
640
- if (prevDigit && (currLower || currUpper)) return true;
641
- return false;
642
- }
643
- /**
644
- * Jaccard similarity between two token multisets. Formula: |A ∩ B| / |A ∪ B|.
645
- * Empty on both sides returns 1.0 (both are "no tokens", which are equivalent for the
646
- * purpose of §3.4.1). Empty on one side only returns 0.0.
647
- */
648
- function jaccard(a, b) {
649
- return jaccardSets(new Set(a), new Set(b));
650
- }
651
- function jaccardSets(a, b) {
652
- if (a.size === 0 && b.size === 0) return 1;
653
- if (a.size === 0 || b.size === 0) return 0;
654
- const [small, large] = a.size <= b.size ? [a, b] : [b, a];
655
- let intersection = 0;
656
- for (const token of small) if (large.has(token)) intersection++;
657
- return intersection / (a.size + b.size - intersection);
658
- }
659
- /**
660
- * Jaccard over the tokens of two strings. Part of the module's surface rather than an
661
- * internal shortcut: the two formulas below go through a token table they share for one
662
- * matching pass, so this has no caller inside this file.
663
- */
664
- function jaccardTokens(a, b) {
665
- return jaccard(tokenizeName(a), tokenizeName(b));
666
- }
667
- const tokenizeEveryTime = (value) => new Set(tokenizeName(value));
668
- /**
669
- * §3.4.1 — Jaccard over the tokens of the full qualified name. The last segment is not
670
- * given extra weight; the entire path contributes uniformly because the intersection is
671
- * what the score should reward.
672
- */
673
- function nameSimilarity(baseName, headName) {
674
- return nameFormula(tokenizeEveryTime, baseName, headName);
675
- }
676
- /**
677
- * §3.4.1 — Jaccard over the tokens of the **last segment**: the member name with its owner
678
- * removed. What §3.4's composite reads, because §3.4.6 decides the owner separately and
679
- * reading it on both axes charges for it twice.
680
- */
681
- function memberSimilarity(baseName, headName) {
682
- return memberFormula(tokenizeEveryTime, baseName, headName);
683
- }
684
- /**
685
- * §3.4.6 (R-8) — whether two Symbols are close enough in *scope* to be the same Symbol: the
686
- * same owner, or one whose owner was renamed. A gate rather than a score, because grading the
687
- * owner cannot do what R-8 asks. `UserRepo.findById` and `AdminRepo.findById` agree on their
688
- * member name and their signature, so a shared `Repo` token at weight 0.2 carries them to
689
- * 0.8667 against a 0.85 threshold — while `UsersRepository.findById`, which *is* the rename,
690
- * shares no owner token and scores 0.8000. The collision outscores the rename, and raising the
691
- * weight only moves both: at 0.3 two three-token class names sharing two tokens land on
692
- * exactly 0.85.
693
- *
694
- * Owner extraction:
695
- * - `Class::method` → `Class`
696
- * - `A.B.C.method` → `A.B.C`
697
- * - `topLevel` → `` (empty)
698
- *
699
- * Two empty owners are compatible: top-level Symbols share the one outer scope. One empty and
700
- * one not are never compatible — the two live at different depths.
701
- *
702
- * Otherwise the owners must correspond segment for segment, and within a segment every token
703
- * on each side must find a distinct partner on the other under `sameWord`. Both sides must be
704
- * covered, so `UserRepo` and `UserRepoV2` are two classes rather than one renamed — an added
705
- * token is as much evidence of a sibling as of a rename, and R-8's business is refusing the
706
- * collision.
707
- */
708
- function ownersAreCompatible(baseName, headName) {
709
- return ownerGate(tokenizeEveryTime, baseName, headName);
710
- }
711
- /**
712
- * §3.4.6 — the tokens `a` and `b` name the same thing.
713
- *
714
- * Equal, or the same word inflected: `user`/`users`, `entity`/`entities`. Nothing else, and
715
- * that is the whole of the rule rather than a first approximation.
716
- *
717
- * A bare prefix test is the obvious generalisation and it cannot be made to work. It admits
718
- * `repo`/`report`, `cache`/`cached`, `con`/`controller` — two distinct classes, which is
719
- * exactly the collision R-8 exists to refuse. Every measure that might separate those from a
720
- * real rename fails, because the two populations interleave rather than sitting on opposite
721
- * sides of anything:
722
- *
723
- * ```
724
- * dice levenshtein
725
- * accept UserRepo/UsersRepository 0.571 7
726
- * reject RepoManager/ReportManager 0.818 2
727
- * accept Repo/Repository 0.500 6
728
- * reject CacheStore/CachedStore 0.842 1
729
- * ```
730
- *
731
- * The renames to accept score *lower* than the collisions to refuse, on both. There is no
732
- * threshold, and a length-growth rule fares no better: `con`→`controller` grows by 7 and
733
- * `user`→`users` by 1, so anything admitting the second admits the first.
734
- *
735
- * Inflection is not on that spectrum. It is a closed, mechanical relation between two spellings
736
- * of one word, so it can be recognised rather than estimated — and it covers the case a plain
737
- * equality test misses most often, a class pluralised in place.
738
- *
739
- * What this gives up is the abbreviation family: `UserRepo` → `UsersRepository` no longer
740
- * clears the gate on `repo`/`repository`. That is a real rename reported as `added` +
741
- * `removed`, and §3.4.6 records it as the price of refusing `repo`/`report`, which no rule
742
- * over the two strings alone can tell apart. The evidence that would settle it is not in the
743
- * strings: the owner is itself a Symbol, and whether *it* paired is the question being
744
- * guessed at here.
745
- */
746
- function sameWord(a, b) {
747
- if (a === b) return true;
748
- const [short, long] = a.length <= b.length ? [a, b] : [b, a];
749
- return pluralises(short, long);
750
- }
751
- /**
752
- * The longest owner segment the matching will search.
753
- *
754
- * `augment` is Kuhn's, so the search is cubic in the token count and recursive with it. A
755
- * class name is a handful of words; anything past this is not a name the gate has an opinion
756
- * about, and `buildDiff` takes IR JSON from a caller rather than only from `aburi scan`, so
757
- * the bound is what turns an adversarial 1600-token identifier from a hang into an answer.
758
- * Refusing is the safe direction: the pair falls to `added` + `removed`.
759
- */
760
- const MAX_OWNER_SEGMENT_TOKENS = 32;
761
- /** English noun inflection, which is what a pluralised class name goes through. */
762
- function pluralises(singular, plural) {
763
- if (plural === `${singular}s` || plural === `${singular}es`) return true;
764
- return singular.endsWith("y") && plural === `${singular.slice(0, -1)}ies`;
765
- }
766
- function nameFormula(setOf, baseName, headName) {
767
- return jaccardSets(setOf(baseName), setOf(headName));
768
- }
769
- function memberFormula(setOf, baseName, headName) {
770
- return jaccardSets(setOf(lastSegment(baseName)), setOf(lastSegment(headName)));
771
- }
772
- function ownerGate(setOf, baseName, headName) {
773
- const baseOwner = extractOwner(baseName);
774
- const headOwner = extractOwner(headName);
775
- if (baseOwner === "" && headOwner === "") return true;
776
- if (baseOwner === "" || headOwner === "") return false;
777
- if (baseOwner === headOwner) return true;
778
- return segmentsCorrespond(baseOwner, headOwner, setOf);
779
- }
780
- /**
781
- * §3.4.6 — the two owners name the same scope: segment for segment, token for token.
782
- *
783
- * Compared per segment rather than over the owner as a whole, because `tokenizeName` dedups
784
- * and an owner is a *path*. `Users.UserRepo` collapses to `{users, user, repo}` while
785
- * `Users.UsersRepository` collapses to `{users, repository}` — the namespace and the class
786
- * share a word, so one side loses a token and the two stop being comparable before any
787
- * spelling is looked at. Segment by segment they line up: `Users`/`Users`, then
788
- * `UserRepo`/`UsersRepository`.
789
- *
790
- * A rename changes what a class is called, not how deeply it is nested, so a differing segment
791
- * count is a differing scope.
792
- */
793
- function segmentsCorrespond(baseOwner, headOwner, setOf) {
794
- const baseSegments = baseOwner.split(".");
795
- const headSegments = headOwner.split(".");
796
- if (baseSegments.length !== headSegments.length) return false;
797
- return baseSegments.every((segment, index) => {
798
- const counterpart = headSegments[index];
799
- if (counterpart === void 0) return false;
800
- if (segment === counterpart) return true;
801
- return coversBothWays(setOf(segment), setOf(counterpart));
802
- });
803
- }
804
- /**
805
- * Whether the two token sets admit a perfect matching under `sameWord`.
806
- *
807
- * Sizes must agree, after which an injection from one side is a bijection — so only one
808
- * direction is searched. The search is augmenting-path rather than greedy, because a greedy
809
- * pass can strand a token a different choice would have matched: over `{user, users}` and
810
- * `{user, userx}`, taking the identical pair first leaves `users` with nothing, though
811
- * `user`→`userx` with `users`→`user` covers both. One segment runs to a handful of tokens, so
812
- * the exact answer costs nothing worth saving.
813
- */
814
- function coversBothWays(a, b) {
815
- if (a.size !== b.size) return false;
816
- if (a.size > MAX_OWNER_SEGMENT_TOKENS) return false;
817
- const right = [...b];
818
- const partnerOf = /* @__PURE__ */ new Map();
819
- for (const token of a) if (!augment(token, right, partnerOf, /* @__PURE__ */ new Set())) return false;
820
- return true;
821
- }
822
- /** Kuhn's step: claim a free partner for `token`, or displace one that can move on. */
823
- function augment(token, right, partnerOf, visited) {
824
- for (const candidate of right) {
825
- if (visited.has(candidate) || !sameWord(token, candidate)) continue;
826
- visited.add(candidate);
827
- const holder = partnerOf.get(candidate);
828
- if (holder === void 0 || augment(holder, right, partnerOf, visited)) {
829
- partnerOf.set(candidate, token);
830
- return true;
831
- }
832
- }
833
- return false;
834
- }
835
- /**
836
- * §3.4.6 — a necessary condition on two non-empty owners, cheaper than the full gate: their
837
- * first segments must correspond, since segments are compared positionally.
838
- *
839
- * A filter, not a decision. It relaxes `coversBothWays`' perfect matching to "every token
840
- * finds some partner", so it can only ever admit more than the gate does; answering `true`
841
- * says the gate has to be asked. Loosening it further costs correctness nothing and speed a
842
- * little — refusing something the gate would accept is the direction that would break recall.
843
- */
844
- function firstSegmentsCouldAgree(baseOwner, headOwner, setOf) {
845
- const baseFirst = baseOwner.slice(0, dotOrEnd(baseOwner));
846
- const headFirst = headOwner.slice(0, dotOrEnd(headOwner));
847
- if (baseFirst === headFirst) return true;
848
- const baseTokens = setOf(baseFirst);
849
- const headTokens = setOf(headFirst);
850
- if (baseTokens.size !== headTokens.size) return false;
851
- if (baseTokens.size > MAX_OWNER_SEGMENT_TOKENS) return false;
852
- for (const token of baseTokens) {
853
- let partnered = false;
854
- for (const candidate of headTokens) if (sameWord(token, candidate)) {
855
- partnered = true;
856
- break;
857
- }
858
- if (!partnered) return false;
859
- }
860
- return true;
861
- }
862
- function dotOrEnd(owner) {
863
- const dot = owner.indexOf(".");
864
- return dot < 0 ? owner.length : dot;
865
- }
866
- /**
867
- * A scorer that tokenises each distinct name once.
868
- *
869
- * Stage 4 scores every (base, head) pair in a bucket, so a bucket of K on each side asks for
870
- * K² similarities over 2K distinct names — tokenising on every call splits the same strings
871
- * into the same tokens hundreds of thousands of times. The table lives for one call rather
872
- * than for the process, because the names it holds are only the ones that pass is comparing.
873
- */
874
- function createNameScorer() {
875
- const sets = /* @__PURE__ */ new Map();
876
- const setOf = (value) => {
877
- const cached = sets.get(value);
878
- if (cached !== void 0) return cached;
879
- const built = new Set(tokenizeName(value));
880
- sets.set(value, built);
881
- return built;
882
- };
883
- const owners = /* @__PURE__ */ new Map();
884
- const ownerOf = (qname) => {
885
- const cached = owners.get(qname);
886
- if (cached !== void 0) return cached;
887
- const built = extractOwner(qname);
888
- owners.set(qname, built);
889
- return built;
890
- };
891
- /**
892
- * §3.4.6's gate, with the segment split and the matching skipped where they can be.
893
- *
894
- * The gate is the costliest thing stage 4 asks per candidate — it splits both owners into
895
- * segments, tokenises each and runs an augmenting-path matching. Two shortcuts, in order of
896
- * how much they save:
897
- *
898
- * - **Identical owners.** A bucket of methods on one class asks about the same owner over
899
- * and over, and `ownerGate` already answers those on a string compare; doing it here skips
900
- * the two `extractOwner` calls as well, which are substrings on every candidate.
901
- * - **A different first segment.** Segments correspond positionally, so owners whose first
902
- * segments cannot be the same word are incompatible whatever the rest says. The check is
903
- * one `sameWord` over two token sets the table already holds, against a full matching over
904
- * every segment.
905
- *
906
- * Not memoised on the pair. A bulk rename produces as many distinct owner pairs as
907
- * candidates, so a table keyed on them grows with the cross-product — enough to exceed
908
- * V8's Map limit on the very input this is meant to make fast.
909
- */
910
- const compatible = (baseName, headName) => {
911
- const baseOwner = ownerOf(baseName);
912
- const headOwner = ownerOf(headName);
913
- if (baseOwner === headOwner) return true;
914
- if (baseOwner === "" || headOwner === "") return false;
915
- if (!firstSegmentsCouldAgree(baseOwner, headOwner, setOf)) return false;
916
- return segmentsCorrespond(baseOwner, headOwner, setOf);
917
- };
918
- return {
919
- name: (baseName, headName) => nameFormula(setOf, baseName, headName),
920
- member: (baseName, headName) => memberFormula(setOf, baseName, headName),
921
- ownersCompatible: compatible
922
- };
923
- }
924
- function extractOwner(qname) {
925
- const staticIdx = qname.indexOf("::");
926
- if (staticIdx >= 0) return qname.slice(0, staticIdx);
927
- const lastDot = qname.lastIndexOf(".");
928
- if (lastDot >= 0) return qname.slice(0, lastDot);
929
- return "";
930
- }
931
- /** §3.4.1 tail note — used by drop-4.5 weak matcher and threshold lookup. */
932
- function lastSegment(qname) {
933
- const staticIdx = qname.indexOf("::");
934
- if (staticIdx >= 0) return qname.slice(staticIdx + 2);
935
- const lastDot = qname.lastIndexOf(".");
936
- if (lastDot >= 0) return qname.slice(lastDot + 1);
937
- return qname;
938
- }
939
-
940
- //#endregion
941
- //#region src/match.ts
942
- /** Fingerprint for the 12-hex-char "everything zero" (i.e. dropped) canonical hash. */
943
- const ZERO_LOGIC_FP = "000000000000";
944
- /** §3.3 — the similarity a multi-candidate logic-fingerprint group must reach to pair. */
945
- const NAME_DISAMBIGUATION_THRESHOLD = .85;
946
- /**
947
- * §3.8 — accept candidate pairings highest score first, taking each base and each head at
948
- * most once. Stages 3, 4 and 4.5 all choose from a set of possible pairings, and all three
949
- * used to decide one head at a time — which let an earlier head consume a base that was a
950
- * later head's exact match, and reported the loser as `added` beside the pair it lost.
951
- *
952
- * The two id sets come back with the pairings because every caller needs them to work out
953
- * what is left over, and deriving them again at the call site is a chance to derive the
954
- * wrong one: reading the head ids out as the base ids typechecks, and reproduces the very
955
- * symptom this exists to prevent.
956
- *
957
- * Not free, and §8.2 carries the bound: `candidates` is every pairing that clears its
958
- * threshold, so this holds O(base × head) records for stages 3 and 4 in the worst case where
959
- * the per-head loop it replaced held one, and sorting them is O(C log C). Stage 4 buys that
960
- * back and more with `createNameScorer`.
961
- *
962
- * It is not an optimal assignment — a greedy sweep can still strand a pair whose partners
963
- * were both taken by higher-scoring ones — but it never passes over the best available
964
- * pairing, which is the case that shows up in a review as one symbol appearing twice.
965
- *
966
- * Stage 4.5 does not call this, because that licence is what it cannot borrow: §3.4.5's
967
- * candidates are all worth the same, so no pairing is the best available and stranding one
968
- * buys nothing. See `bestPairing`.
969
- */
970
- function acceptInScoreOrder(candidates) {
971
- candidates.sort(compareCandidates);
972
- const baseIds = /* @__PURE__ */ new Set();
973
- const headIds = /* @__PURE__ */ new Set();
974
- const pairs = [];
975
- for (const candidate of candidates) {
976
- if (baseIds.has(candidate.base.id) || headIds.has(candidate.head.id)) continue;
977
- baseIds.add(candidate.base.id);
978
- headIds.add(candidate.head.id);
979
- pairs.push(candidate);
980
- }
981
- return {
982
- pairs,
983
- baseIds,
984
- headIds
985
- };
986
- }
987
- /**
988
- * Highest score first, then `(base.id, head.id)` ascending. The two id keys are what make
989
- * the order total, and they are a total order only because ids are unique within a Document
990
- * (ir-schema.md §14 #1), which `buildDiff` establishes before the first stage runs. Without
991
- * them equal scores resolve to enumeration order, and the diff stops being a function of the
992
- * two Documents.
993
- */
994
- function compareCandidates(a, b) {
995
- if (a.score !== b.score) return b.score - a.score;
996
- if (a.base.id !== b.base.id) return a.base.id < b.base.id ? -1 : 1;
997
- return a.head.id < b.head.id ? -1 : a.head.id > b.head.id ? 1 : 0;
998
- }
999
- /** The entries of `symbols` that no accepted pairing claimed, in their original order. */
1000
- function unclaimed(symbols, claimed) {
1001
- return symbols.filter((symbol) => !claimed.has(symbol.id));
1002
- }
1003
- /**
1004
- * §3.1 — segments Symbols with identical `id` into paired matches. Runs first because it
1005
- * is the highest-confidence signal (no heuristics, just hash lookup).
1006
- *
1007
- * Assumes `id` is unique on each side (ir-schema.md §14 #1) and does not check it:
1008
- * `buildDiff` establishes that before calling, and a caller reaching this export directly
1009
- * owns the obligation. A repeat on the head side loses all but the last entry to the lookup
1010
- * map and then removes every one of them from `remainingHead`; a repeat on the base side
1011
- * pairs the same head Symbol more than once. The later stages track consumed base Symbols
1012
- * by id and lose a repeat the same way.
1013
- */
1014
- function matchStageId(base, head) {
1015
- const headById = /* @__PURE__ */ new Map();
1016
- for (const s of head) headById.set(s.id, s);
1017
- const matched = [];
1018
- const remainingBase = [];
1019
- const usedHead = /* @__PURE__ */ new Set();
1020
- for (const b of base) {
1021
- const h = headById.get(b.id);
1022
- if (h !== void 0) {
1023
- matched.push({
1024
- base: b,
1025
- head: h,
1026
- rationale: "id-match"
1027
- });
1028
- usedHead.add(b.id);
1029
- } else remainingBase.push(b);
1030
- }
1031
- return {
1032
- matched,
1033
- remainingBase,
1034
- remainingHead: head.filter((h) => !usedHead.has(h.id))
1035
- };
1036
- }
1037
- /**
1038
- * §3.2 — git rename detection. When a rename map is available (typical of ref-driven
1039
- * scans), rewrite the base id with the head-side file path and look for a hit. Two base
1040
- * files renamed onto one target predict the same head id, so the claimants are collected
1041
- * before one is chosen.
1042
- *
1043
- * The rewriter only touches the `file` portion of the id — the `<language>:` prefix and
1044
- * the trailing `#<qualified-name>` segment stay unchanged so a moved file with the same
1045
- * qualified name is picked up correctly. When a Symbol's file is not in the rename map,
1046
- * it is left for later stages.
1047
- */
1048
- function matchStageGitRename(remainingBase, remainingHead, renameMap) {
1049
- if (renameMap === null || renameMap.size === 0) return {
1050
- matched: [],
1051
- remainingBase: [...remainingBase],
1052
- remainingHead: [...remainingHead]
1053
- };
1054
- const headById = /* @__PURE__ */ new Map();
1055
- for (const h of remainingHead) headById.set(h.id, h);
1056
- const claimants = /* @__PURE__ */ new Map();
1057
- for (const b of remainingBase) {
1058
- const newPath = renameMap.get(b.source.file);
1059
- if (newPath === void 0) continue;
1060
- const expectedId = rewriteIdFile(b.id, b.source.file, newPath);
1061
- if (expectedId === null) continue;
1062
- const head = headById.get(expectedId);
1063
- if (head === void 0) continue;
1064
- const claim = claimants.get(expectedId);
1065
- if (claim === void 0) claimants.set(expectedId, {
1066
- head,
1067
- bases: [b]
1068
- });
1069
- else claim.bases.push(b);
1070
- }
1071
- const matched = [];
1072
- const usedBase = /* @__PURE__ */ new Set();
1073
- const usedHead = /* @__PURE__ */ new Set();
1074
- for (const { head, bases } of claimants.values()) {
1075
- const base = lowestId(bases);
1076
- matched.push({
1077
- base,
1078
- head,
1079
- rationale: "git-rename"
1080
- });
1081
- usedBase.add(base.id);
1082
- usedHead.add(head.id);
1083
- }
1084
- return {
1085
- matched,
1086
- remainingBase: unclaimed(remainingBase, usedBase),
1087
- remainingHead: unclaimed(remainingHead, usedHead)
1088
- };
1089
- }
1090
- /** Total on the non-empty claim lists above: a claim is created holding its first base. */
1091
- function lowestId(symbols) {
1092
- let lowest = symbols[0];
1093
- for (const symbol of symbols) if (symbol.id < lowest.id) lowest = symbol;
1094
- return lowest;
1095
- }
1096
- /**
1097
- * Predict the id a base Symbol would carry after git moved its file. The second Symbol-id
1098
- * derivation in the codebase after `makeSymbolId` itself, so it goes back through the same
1099
- * constructor rather than re-concatenating the parts: `trySymbolId` rejects a rename target
1100
- * the id grammar cannot express (a backslash path, say) instead of minting an id no head
1101
- * Symbol can ever equal.
1102
- *
1103
- * Returns `null` for that case, and the unchanged id when no rename applies — the caller
1104
- * treats a null the same way it treats a lookup miss, leaving the pair for stage 3.
1105
- */
1106
- function rewriteIdFile(id, oldPath, newPath) {
1107
- const colon = id.indexOf(":");
1108
- const hash = id.indexOf("#");
1109
- if (colon < 0 || hash < 0 || hash < colon) return id;
1110
- if (id.slice(colon + 1, hash) !== oldPath) return id;
1111
- return trySymbolId({
1112
- language: id.slice(0, colon),
1113
- file: newPath,
1114
- qualifiedName: id.slice(hash + 1)
1115
- });
1116
- }
1117
- /**
1118
- * §3.3 — group both sides by `fingerprint.logic` and pair within each group. Two branches:
1119
- * - single base candidate → paired with `logic-fingerprint`, no similarity test
1120
- * - several → `nameSimilarity` disambiguates at ≥ 0.85; a group that cannot reach it is left
1121
- * whole for stage 4 to re-evaluate
1122
- *
1123
- * Dropped symbols are excluded (§3.3 tail) — their logic fingerprint is the sentinel
1124
- * `"000000000000"` and would collide with every other dropped Symbol in the workspace.
1125
- * They flow to the stage-4.5 weak matcher instead.
1126
- */
1127
- function matchStageLogicFingerprint(remainingBase, remainingHead) {
1128
- const baseGroups = groupByLogic(remainingBase);
1129
- const headGroups = groupByLogic(remainingHead);
1130
- const scorer = createNameScorer();
1131
- const matched = [];
1132
- for (const [logic, heads] of headGroups) {
1133
- const bases = baseGroups.get(logic);
1134
- if (bases === void 0) continue;
1135
- matched.push(...pairWithinLogicGroup(bases, heads, scorer));
1136
- }
1137
- const usedBase = new Set(matched.map((pair) => pair.base.id));
1138
- const usedHead = new Set(matched.map((pair) => pair.head.id));
1139
- return {
1140
- matched,
1141
- remainingBase: unclaimed(remainingBase, usedBase),
1142
- remainingHead: unclaimed(remainingHead, usedHead)
1143
- };
1144
- }
1145
- /**
1146
- * Symbols by logic fingerprint, skipping the ones §3.3 excludes. Both sides are grouped, so
1147
- * a group is a self-contained problem: the Symbols in it can only pair with each other, and
1148
- * no group’s outcome depends on any other’s.
1149
- */
1150
- function groupByLogic(symbols) {
1151
- const groups = /* @__PURE__ */ new Map();
1152
- for (const symbol of symbols) {
1153
- if (symbol.dropped || symbol.fingerprint.logic === ZERO_LOGIC_FP) continue;
1154
- const bucket = groups.get(symbol.fingerprint.logic) ?? [];
1155
- bucket.push(symbol);
1156
- groups.set(symbol.fingerprint.logic, bucket);
1157
- }
1158
- return groups;
1159
- }
1160
- /**
1161
- * §3.3’s two branches, over one logic-fingerprint group.
1162
- *
1163
- * A lone base candidate pairs with no similarity test — identical logic is taken as proof on
1164
- * its own. With more than one, names disambiguate and a group that cannot reach the
1165
- * threshold is left whole for stage 4.
1166
- *
1167
- * The loop is what keeps the second branch feeding the first: a scored round that consumes
1168
- * all but one base leaves that one unconditional, which is how the per-head version behaved
1169
- * when its candidate list shrank to a single entry.
1170
- */
1171
- function pairWithinLogicGroup(bases, heads, scorer) {
1172
- let freeBase = [...bases];
1173
- let freeHead = [...heads];
1174
- const matched = [];
1175
- while (freeBase.length > 0 && freeHead.length > 0) {
1176
- const lone = freeBase.length === 1 ? freeBase[0] : void 0;
1177
- if (lone !== void 0) {
1178
- const head = closestNameTo(lone, freeHead, scorer);
1179
- if (head === void 0) break;
1180
- matched.push({
1181
- base: lone,
1182
- head,
1183
- rationale: "logic-fingerprint"
1184
- });
1185
- break;
1186
- }
1187
- const candidates = [];
1188
- for (const base of freeBase) for (const head of freeHead) {
1189
- const score = scorer.name(base.name, head.name);
1190
- if (score >= NAME_DISAMBIGUATION_THRESHOLD) candidates.push({
1191
- base,
1192
- head,
1193
- score
1194
- });
1195
- }
1196
- const accepted = acceptInScoreOrder(candidates);
1197
- if (accepted.pairs.length === 0) break;
1198
- for (const { base, head } of accepted.pairs) matched.push({
1199
- base,
1200
- head,
1201
- rationale: "logic-fingerprint+name-disambiguation"
1202
- });
1203
- freeBase = unclaimed(freeBase, accepted.baseIds);
1204
- freeHead = unclaimed(freeHead, accepted.headIds);
1205
- }
1206
- return matched;
1207
- }
1208
- /**
1209
- * The head whose name is closest to `base`, ties going to the lower id. The lone-candidate
1210
- * branch pairs whatever it is given, so this only decides *which* head it takes — but that
1211
- * decision was array order, and it is visible in the diff.
1212
- */
1213
- function closestNameTo(base, heads, scorer) {
1214
- let best;
1215
- for (const head of heads) {
1216
- const score = scorer.name(base.name, head.name);
1217
- if (best === void 0 || score > best.score) {
1218
- best = {
1219
- head,
1220
- score
1221
- };
1222
- continue;
1223
- }
1224
- if (score === best.score && head.id < best.head.id) best = {
1225
- head,
1226
- score
1227
- };
1228
- }
1229
- return best?.head;
1230
- }
1231
- /**
1232
- * §3.4 — name + signature similarity with `(kind, signatureNullness)` bucket pre-filter.
1233
- * `signatureNullness` distinguishes symbols with no signature (`interface`, `type`,
1234
- * `class`) from callable ones so that a class body is never paired against a function.
1235
- *
1236
- * §3.4.6's owner gate runs before the score: a pair whose owners are not the same class, or
1237
- * one a rename of the other, is not the same Symbol however well the rest agrees, and no
1238
- * weight small enough to leave the name axis meaning something can outvote a matching member
1239
- * name and signature.
1240
- *
1241
- * The composite score, over the pairs that clear it:
1242
- * 0.5 * memberSimilarity + 0.3 * signatureSimilarity + 0.2 * OWNER_AXIS_SATISFIED
1243
- *
1244
- * `memberSimilarity` reads the last segment, not the whole qualified name: the gate has
1245
- * already settled the owner, and reading it again on the name axis charges for it twice —
1246
- * which is what kept a renamed class from ever pairing (§3.4.6).
1247
- *
1248
- * §3.4.3 threshold table, applied per head by the token count of its last name segment:
1249
- * - 1 token → 1.0 (an exact match on both remaining axes)
1250
- * - 2 tokens → 0.95 (strict, avoids `getUser` vs `getUsers`)
1251
- * - default → 0.85
1252
- *
1253
- * §3.4.3 also names two Symbols the stage does not read at all, because for them the score is
1254
- * high on evidence it does not have rather than on a resemblance: one whose signature is
1255
- * null, since `signatureSimilarity(null, null)` is 1.0 and the bucket key restricts it to
1256
- * candidates that are equally signature-less; and one whose qualified name carries a single
1257
- * distinct token, since that is the whole of what its identity amounts to. Neither can be
1258
- * expressed as a threshold — the second wants a bar above the top of the scale.
1259
- *
1260
- * Every pairing in a bucket that clears its head’s threshold becomes a candidate, and the
1261
- * candidates are settled in score order rather than one head at a time — see
1262
- * `acceptInScoreOrder`. That doubles the pairings scored, from a bucket that shrank as heads
1263
- * consumed it to the full bucket every time; `createNameScorer` more than pays for it by
1264
- * tokenising each distinct name once per pass instead of once per comparison.
1265
- */
1266
- function matchStageNameSignature(remainingBase, remainingHead) {
1267
- const buckets = /* @__PURE__ */ new Map();
1268
- for (const b of remainingBase) {
1269
- if (b.dropped) continue;
1270
- if (!saysEnoughToPair(b.name)) continue;
1271
- const key = bucketKey(b);
1272
- const bucket = buckets.get(key) ?? {
1273
- members: [],
1274
- byToken: /* @__PURE__ */ new Map()
1275
- };
1276
- const position = bucket.members.length;
1277
- bucket.members.push(b);
1278
- for (const token of memberTokens(b.name)) {
1279
- const sharing = bucket.byToken.get(token) ?? [];
1280
- sharing.push(position);
1281
- bucket.byToken.set(token, sharing);
1282
- }
1283
- buckets.set(key, bucket);
1284
- }
1285
- const visitsOf = /* @__PURE__ */ new Map();
1286
- for (const bucket of buckets.values()) visitsOf.set(bucket, new Int32Array(bucket.members.length));
1287
- const scorer = createNameScorer();
1288
- const candidates = [];
1289
- let visitCount = 0;
1290
- for (const h of remainingHead) {
1291
- if (h.dropped) continue;
1292
- if (h.signature === null || h.signature === void 0) continue;
1293
- if (!saysEnoughToPair(h.name)) continue;
1294
- const bucket = buckets.get(bucketKey(h));
1295
- if (bucket === void 0) continue;
1296
- const threshold = thresholdFor(h.name);
1297
- const memberFloor = lowestUsefulMember(threshold);
1298
- const tokens = memberTokens(h.name);
1299
- let reach = 0;
1300
- for (const token of tokens) reach += bucket.byToken.get(token)?.length ?? 0;
1301
- if (reach >= bucket.members.length) {
1302
- for (const b of bucket.members) collectCandidate(b, h, scorer, memberFloor, threshold, candidates);
1303
- continue;
1304
- }
1305
- const visited = visitsOf.get(bucket);
1306
- if (visited === void 0) continue;
1307
- visitCount++;
1308
- for (const token of tokens) for (const position of bucket.byToken.get(token) ?? []) {
1309
- if (visited[position] === visitCount) continue;
1310
- visited[position] = visitCount;
1311
- const b = bucket.members[position];
1312
- if (b !== void 0) collectCandidate(b, h, scorer, memberFloor, threshold, candidates);
1313
- }
1314
- }
1315
- const accepted = acceptInScoreOrder(candidates);
1316
- return {
1317
- matched: accepted.pairs.map(({ base, head }) => ({
1318
- base,
1319
- head,
1320
- rationale: "name-signature"
1321
- })),
1322
- remainingBase: unclaimed(remainingBase, accepted.baseIds),
1323
- remainingHead: unclaimed(remainingHead, accepted.headIds)
1324
- };
1325
- }
1326
- /**
1327
- * §3.4 — score one candidate pairing and keep it if it clears the head's threshold.
1328
- *
1329
- * A plain function rather than a closure over the head loop: both branches of the walk need
1330
- * it, and a closure built per head is the cross-product moved into the allocator — measurably
1331
- * so, at around 1.6x on a bucket the index cannot narrow.
1332
- *
1333
- * §3.4.3's floor is read before §3.4.6's gate and is the only early exit here. It is one
1334
- * Jaccard over token sets the pass already holds, where the gate splits both owners into
1335
- * segments, tokenises each and runs an augmenting-path matching. The gate short-circuits
1336
- * internally on identical owners and on first segments that cannot correspond, so there is
1337
- * nothing left worth hoisting out of it.
1338
- */
1339
- function collectCandidate(base, head, scorer, memberFloor, threshold, candidates) {
1340
- const member = scorer.member(base.name, head.name);
1341
- if (member < memberFloor) return;
1342
- if (!scorer.ownersCompatible(base.name, head.name)) return;
1343
- const score = .5 * member + .3 * signatureSimilarity(base.signature ?? null, head.signature ?? null) + .2 * OWNER_AXIS_SATISFIED;
1344
- if (score >= threshold) candidates.push({
1345
- base,
1346
- head,
1347
- score
1348
- });
1349
- }
1350
- function bucketKey(s) {
1351
- const sig = s.signature === null || s.signature === void 0 ? "no-sig" : "has-sig";
1352
- return `${s.kind}::${sig}`;
1353
- }
1354
- /**
1355
- * §3.4.0 — the least `memberSimilarity` that leaves `threshold` reachable.
1356
- *
1357
- * `0.5 * member + 0.3 * signature + 0.2 * 1` must reach the threshold, and the signature axis
1358
- * is worth at most 0.3, so `member >= 2 * (threshold - 0.5)`. Refusing below this is exact
1359
- * rather than approximate: nothing the other two axes can do rescues a pair under it.
1360
- *
1361
- * Reading it before §3.4.6's gate is what keeps stage 4 off the cross-product. The gate splits
1362
- * and tokenises two owners; this is one Jaccard over token sets the pass has already built,
1363
- * and it refuses most of what a shared token admits.
1364
- */
1365
- function lowestUsefulMember(threshold) {
1366
- return 2 * (threshold - .5);
1367
- }
1368
- /**
1369
- * §3.4.0 — the token that stands in for a member name carrying none.
1370
- *
1371
- * `Foo.Bar.` has an empty last segment and two tokens in its qualified name, so it is
1372
- * admissible, and two such Symbols score 1.0 on an axis comparing two empty sets. They have
1373
- * no token to be indexed under and cannot pair with anything that has one, so they share a
1374
- * key of their own. A real token cannot collide with it: `tokenizeName` never emits a space.
1375
- */
1376
- const NO_MEMBER_TOKENS = " none";
1377
- function memberTokens(qname) {
1378
- const tokens = tokenizeName(lastSegment(qname));
1379
- return tokens.length === 0 ? [NO_MEMBER_TOKENS] : tokens;
1380
- }
1381
- /**
1382
- * §3.4.3 — the composite score a pair must reach, by how many tokens the head's last name
1383
- * segment has. A one-token segment still asks for the whole scale, which a pair reaches on an
1384
- * identical member name and signature past §3.4.6's gate: `0.5 + 0.3 + 0.2`, exactly 1 in
1385
- * IEEE 754. The owner need not be identical, only compatible — `UserRepo.get` and
1386
- * `UserRepos.get` reach it.
1387
- */
1388
- const EXACT_MATCH_ONLY = 1;
1389
- const TWO_TOKEN_THRESHOLD = .95;
1390
- const DEFAULT_THRESHOLD = .85;
1391
- /**
1392
- * §3.4.6 — what the owner axis is worth to a pair that clears its gate, which is every pair
1393
- * that reaches the score. Written as a value rather than folded into the weights so the
1394
- * composite keeps the 0.5/0.3/0.2 shape §3.4.3's rows are calibrated against: dropping the
1395
- * term and renormalising would move every threshold without changing what any of them means.
1396
- */
1397
- const OWNER_AXIS_SATISFIED = 1;
1398
- function thresholdFor(qname) {
1399
- const tokens = tokenizeName(lastSegment(qname)).length;
1400
- if (tokens <= 1) return EXACT_MATCH_ONLY;
1401
- if (tokens === 2) return TWO_TOKEN_THRESHOLD;
1402
- return DEFAULT_THRESHOLD;
1403
- }
1404
- /**
1405
- * §3.4.3 — whether a Symbol's qualified name says enough for stage 4 to read it at all.
1406
- *
1407
- * The score is built from three things the name supplies: its tokens, its owner's tokens,
1408
- * and a signature. A name of one distinct token supplies one word and an owner that either
1409
- * repeats it or is empty, and `main(x: string): void` against another `main(x: string): void`
1410
- * scores the full 1.0 on that — two unrelated CLI entry points reported as one move. The
1411
- * table's first row was written to refuse this and could not: 1.0 is the top of the scale,
1412
- * so no threshold sits above it. Being unpairable is a property of the name, not a score it
1413
- * failed to reach, so it is decided here rather than by the comparison.
1414
- *
1415
- * Counted over the whole qualified name, because that is what the score reads.
1416
- * `UserRepo.get` supplies three tokens and pairs, though its last segment is one — the
1417
- * measure `thresholdFor` uses, and the wrong one for this question. Tokens are deduped, so
1418
- * `Main.main` supplies one and does not.
1419
- *
1420
- * Read off both sides, because the property belongs to a pairing. It was once read off the
1421
- * head alone, on the arithmetic that a short name anywhere capped the score at 0.75 — true
1422
- * while the name axis read the whole qualified name, and false since §3.4.6 became a gate and
1423
- * the axis moved to the last segment. `Main.main` is one deduped token and clears the gate
1424
- * against `Mains.main`, whose member name is identical, so the pair reaches the top of the
1425
- * scale from a name saying one word.
1426
- *
1427
- * The count stands in for how much the name says, and `tokenizeName` splits on ASCII case
1428
- * boundaries, so a name written in a script that has none is one token however long it is:
1429
- * `ユーザー情報を取得する` is refused here on the same footing as `main`. For that name the
1430
- * proxy is wrong — two unrelated Symbols do not carry it by coincidence the way two carry
1431
- * `main` — and the cost is a stage-4 move this stage will not find. Fixing it means measuring
1432
- * the name by something other than a bare token count, which is §3.4.1's to change.
1433
- */
1434
- function saysEnoughToPair(qname) {
1435
- return tokenizeName(qname).length > 1;
1436
- }
1437
- /**
1438
- * §3.4.5 — dropped-only weak matcher. For dropped Symbols the fingerprint is zeroed, so the
1439
- * only signals left are the trailing segment of the qualified name and the file basename.
1440
- * Either one alone is enough to pair, which is deliberately lax: §3.4.5 accepts a
1441
- * false-positive risk because dropped Symbols sit outside the IR's main review surface.
1442
- *
1443
- * What it does not accept is a signal that identifies nothing. A basename hit on `index.ts`
1444
- * — the most common filename in a TypeScript monorepo — paired every dropped Symbol of one
1445
- * kind under one with every other, at a score they all tied on, and the results landed in
1446
- * `summary.moved`, which `--fail-on moved` gates on. So a half counts only when exactly one
1447
- * dropped base and one dropped head carry that key: such a key names a pair, where a key
1448
- * several Symbols carry names a group, and a group is not a pairing.
1449
- *
1450
- * That makes the candidates the identifying keys themselves — at most one pairing each,
1451
- * two axes, so at most `2 × min(base, head)` of them rather than the cross-product a shared
1452
- * basename used to produce.
1453
- *
1454
- * "Exactly one" is counted over the Symbols this stage is handed, not over every dropped
1455
- * Symbol in the Document — stages 1 and 2 have already taken theirs, and a key they emptied
1456
- * out identifies again. That is the intended reading, since the question is which leftover a
1457
- * key picks out, but it is also how a shared `index.ts` comes back: three dropped classes
1458
- * under one, two of them matched by id, and the basename identifies the two that are left.
1459
- *
1460
- * It also leaves nothing for §3.4.5's 0.5-per-half scale to rank, so §3.8's sweep is the
1461
- * wrong instrument: it settles conflicts by score, and with none to settle by it would drop
1462
- * one identified pairing for another over nothing but the id it sorts under. `bestPairing`
1463
- * takes a maximum matching instead.
1464
- */
1465
- function matchStageDroppedWeak(remainingBase, remainingHead) {
1466
- const bases = remainingBase.filter((s) => s.dropped);
1467
- const heads = remainingHead.filter((s) => s.dropped);
1468
- const identified = [];
1469
- for (const keyOf of [nameKey, fileKey]) identified.push(...pairsIdentifiedBy(bases, heads, keyOf));
1470
- const matched = bestPairing(identified);
1471
- return {
1472
- matched: matched.map(({ base, head }) => ({
1473
- base,
1474
- head,
1475
- rationale: "dropped-weak-match"
1476
- })),
1477
- remainingBase: unclaimed(remainingBase, new Set(matched.map((edge) => edge.base.id))),
1478
- remainingHead: unclaimed(remainingHead, new Set(matched.map((edge) => edge.head.id)))
1479
- };
1480
- }
1481
- /**
1482
- * As many of the identified pairings as can hold at once.
1483
- *
1484
- * Each axis identifies a Symbol at most once, so a Symbol carries at most two pairings and
1485
- * the whole set is the union of two matchings — which is a disjoint union of simple paths and
1486
- * even cycles. Alternate pairings along a component are therefore a maximum matching of it,
1487
- * and walking each component from a fixed end makes the choice among the maximum matchings
1488
- * canonical rather than a property of the input order.
1489
- *
1490
- * The alternative would be §3.8's sweep, and it is the wrong instrument once §3.4.5's score
1491
- * is gone: over `Shared.ts#Alpha`, `Other.ts#Beta` against `Other.ts#Alpha`,
1492
- * `Shared.ts#Gamma`, all three pairings are identified and it takes one where two hold.
1493
- */
1494
- function bestPairing(edges) {
1495
- const ordered = [...edges].sort(byEndpoints);
1496
- const atBase = /* @__PURE__ */ new Map();
1497
- const atHead = /* @__PURE__ */ new Map();
1498
- for (const [index, edge] of ordered.entries()) {
1499
- appendTo(atBase, edge.base.id, index);
1500
- appendTo(atHead, edge.head.id, index);
1501
- }
1502
- const adjacent = (index) => {
1503
- const edge = ordered[index];
1504
- if (edge === void 0) return [];
1505
- return [...atBase.get(edge.base.id) ?? [], ...atHead.get(edge.head.id) ?? []].filter((other) => other !== index);
1506
- };
1507
- const walked = ordered.map(() => false);
1508
- const matching = [];
1509
- /** Follow a component from `start`, taking every other pairing along it. */
1510
- const walkFrom = (start) => {
1511
- let step = start;
1512
- let take = true;
1513
- while (!walked[step]) {
1514
- walked[step] = true;
1515
- const edge = ordered[step];
1516
- if (take && edge !== void 0) matching.push(edge);
1517
- take = !take;
1518
- const next = adjacent(step).find((other) => walked[other] === false);
1519
- if (next === void 0) return;
1520
- step = next;
1521
- }
1522
- };
1523
- for (const [index] of ordered.entries()) if (!walked[index] && adjacent(index).length <= 1) walkFrom(index);
1524
- for (const [index] of ordered.entries()) if (!walked[index]) walkFrom(index);
1525
- return matching;
1526
- }
1527
- function byEndpoints(a, b) {
1528
- if (a.base.id !== b.base.id) return a.base.id < b.base.id ? -1 : 1;
1529
- return a.head.id < b.head.id ? -1 : a.head.id > b.head.id ? 1 : 0;
1530
- }
1531
- function appendTo(table, key, index) {
1532
- const bucket = table.get(key);
1533
- if (bucket === void 0) table.set(key, [index]);
1534
- else bucket.push(index);
1535
- }
1536
- /**
1537
- * The pairings a key picks out on its own: those whose key exactly one dropped base and one
1538
- * dropped head carry. A key held by two Symbols on either side is discarded rather than
1539
- * resolved — with the fingerprint zeroed there is nothing left to tell the members apart, and
1540
- * guessing between them is the false pairing this rule exists to refuse.
1541
- */
1542
- function pairsIdentifiedBy(bases, heads, keyOf) {
1543
- const soleBase = soleCarriers(bases, keyOf);
1544
- const soleHead = soleCarriers(heads, keyOf);
1545
- const pairs = [];
1546
- for (const [key, base] of soleBase) {
1547
- const head = soleHead.get(key);
1548
- if (head !== void 0) pairs.push({
1549
- base,
1550
- head
1551
- });
1552
- }
1553
- return pairs;
1554
- }
1555
- /** Keys carried by exactly one of `symbols`, mapped to it. */
1556
- function soleCarriers(symbols, keyOf) {
1557
- const sole = /* @__PURE__ */ new Map();
1558
- const shared = /* @__PURE__ */ new Set();
1559
- for (const symbol of symbols) {
1560
- const key = keyOf(symbol);
1561
- if (shared.has(key)) continue;
1562
- if (sole.has(key)) {
1563
- sole.delete(key);
1564
- shared.add(key);
1565
- continue;
1566
- }
1567
- sole.set(key, symbol);
1568
- }
1569
- return sole;
1570
- }
1571
- /**
1572
- * The two halves §3.4.5 scores. `kind` leads because it gates a pair before either half is
1573
- * read, and `/` separates because no kind, qualified-name segment or basename contains one.
1574
- */
1575
- function nameKey(symbol) {
1576
- return `${symbol.kind}/${lastSegment(symbol.name)}`;
1577
- }
1578
- function fileKey(symbol) {
1579
- return `${symbol.kind}/${basename(symbol.source.file)}`;
1580
- }
1581
- function basename(path) {
1582
- const slash = path.lastIndexOf("/");
1583
- return slash >= 0 ? path.slice(slash + 1) : path;
1584
- }
1585
-
1586
- //#endregion
1587
- //#region src/slice.ts
1588
- /**
1589
- * Slice View clustering pass — docs/design/slice-view.md §2, §4–§8.
1590
- *
1591
- * Groups the changed-Symbol set of a diff into weakly-connected components
1592
- * over the union of base and head call edges. Emits a `SliceRecord[]` whose
1593
- * cluster ids are `"slice:" + <smallest-member Symbol id>`, both `slices[]`
1594
- * and each `members[]` sorted ascending.
1595
- *
1596
- * Pure function: `(changes, baseCallEdges, headCallEdges)` → `SliceRecord[]`.
1597
- * Determinism, idempotence, input-order insensitivity, and locality are all
1598
- * guaranteed — see `computeWeaklyConnectedComponents` in `@aburi/core`.
1599
- *
1600
- * Returns `[]` when no SymbolChange is Node-eligible per §4.1; callers should
1601
- * still serialise the empty array — the Markdown projection omits the section
1602
- * (§9.4 / §12.5) but the JSON always emits the key so consumers never
1603
- * distinguish "field absent" from "no slices" (§11.2).
1604
- *
1605
- * Every returned record has passed `assertSliceRecordInvariant` (§7.4).
1606
- */
1607
- function computeSlices(input) {
1608
- const nodeIds = collectNodeIds(input.changes);
1609
- if (nodeIds.length === 0) return [];
1610
- const nodeIdSet = new Set(nodeIds);
1611
- return computeWeaklyConnectedComponents(nodeIds, collectEdges(input.baseCallEdges, input.headCallEdges, nodeIdSet), (id) => id).map(makeSliceRecord);
1612
- }
1613
- const SLICE_ID_PREFIX = "slice:";
1614
- /**
1615
- * §7.1 — the single place production code derives a Slice id. Everything else
1616
- * in `src/` either receives an id or renders one. (Tests spell the prefix out
1617
- * literally on purpose: an expectation written in terms of the function under
1618
- * test would agree with it no matter what it produced.)
1619
- *
1620
- * The cast is the one place a `SliceId` comes into existence. `SliceId` and
1621
- * `SymbolId` are separate brands precisely so this concatenation cannot be
1622
- * open-coded anywhere else: a bare `"slice:" + x` evaluates to `string`, which
1623
- * `SliceRecord.id` no longer accepts.
1624
- */
1625
- function sliceIdFor(anchor) {
1626
- return `${SLICE_ID_PREFIX}${anchor}`;
1627
- }
1628
- /**
1629
- * Build one SliceRecord from an ascending-sorted component and check its own
1630
- * post-condition before letting it out (§7.4 layer 1).
1631
- *
1632
- * The check is not defending against untrusted input — this function builds
1633
- * the id itself, so the derivation clause is true by construction here and
1634
- * only the two clauses about `members` can actually fire. Those are the point:
1635
- * `members[0]` is the anchor only because `computeWeaklyConnectedComponents`
1636
- * returns each component sorted ascending, a guarantee that lives one layer
1637
- * down and is invisible from this file. If it ever stops holding, the diff
1638
- * would otherwise keep emitting well-formed-looking SliceRecords naming the
1639
- * wrong anchor. Here it fails instead.
1640
- */
1641
- function makeSliceRecord(members) {
1642
- const anchor = members[0];
1643
- if (anchor === void 0) throw new DiffError("computeSlices: the clustering utility returned an empty component; every weakly-connected component contains at least the node that seeded it (slice-view.md §6).", { code: "slice-invariant-violated" });
1644
- const record = {
1645
- id: sliceIdFor(anchor),
1646
- members: [...members]
1647
- };
1648
- assertSliceRecordInvariant(record);
1649
- return record;
1650
- }
1651
- /**
1652
- * §7.1 — the anchor of a Slice: its lexicographically smallest member.
1653
- *
1654
- * Answers from `members[0]`. `id` is derived from the anchor, so
1655
- * reconstructing the anchor by stripping the `"slice:"` prefix is circular at
1656
- * best and, for a record that broke the derivation, silently wrong: it would
1657
- * name a Symbol that is not in the Slice. `id` is read here only to name the
1658
- * offending record in the error message. Consumers that need the anchor call
1659
- * this; consumers that need a label keep using `id` directly.
1660
- */
1661
- function sliceAnchor(record) {
1662
- const anchor = record.members[0];
1663
- if (anchor === void 0) {
1664
- const violation = emptyMembersViolation(record.id);
1665
- throw new DiffError(violation.message, {
1666
- code: "slice-invariant-violated",
1667
- value: violation.subject
1668
- });
1669
- }
1670
- return anchor;
1671
- }
1672
- /**
1673
- * Report which clause of the §7.1 / §8.2 invariant a value breaks, or `null`
1674
- * when it is a well-formed `SliceRecord`. Non-throwing counterpart of
1675
- * `assertSliceRecordInvariant`: the pass wants an exception, a schema
1676
- * validator wants a verdict it can turn into its own error.
1677
- *
1678
- * Takes `unknown` rather than `SliceRecord` deliberately. §7.4 layer 2 points
1679
- * this at documents written by third-party or older producers — data that has
1680
- * not been type-checked by definition — so anything that assumed a well-typed
1681
- * argument would crash on exactly the input it exists to reject.
1682
- */
1683
- function sliceRecordViolation(value) {
1684
- if (typeof value !== "object" || value === null) return {
1685
- kind: "malformed-shape",
1686
- subject: "<not an object>",
1687
- message: `Expected a SliceRecord object; got ${value === null ? "null" : typeof value}.`
1688
- };
1689
- const { id, members } = value;
1690
- const subject = typeof id === "string" ? id : "<missing id>";
1691
- if (typeof id !== "string") return {
1692
- kind: "malformed-shape",
1693
- subject,
1694
- message: `SliceRecord ${subject}: id must be a string; got ${typeof id}.`
1695
- };
1696
- if (!Array.isArray(members) || members.some((member) => typeof member !== "string")) return {
1697
- kind: "malformed-shape",
1698
- subject,
1699
- message: `SliceRecord ${subject}: members must be an array of strings.`
1700
- };
1701
- const anchor = members[0];
1702
- if (anchor === void 0) return emptyMembersViolation(subject);
1703
- const reservedAnchor = reservedNamespaceOf(anchor);
1704
- if (reservedAnchor !== null) return {
1705
- kind: "anchor-in-reserved-namespace",
1706
- subject,
1707
- message: `SliceRecord anchor "${anchor}" uses the reserved language token "${reservedAnchor}", so its Slice id would repeat the prefix (ir-schema.md §3.5, slice-view.md §7.5).`
1708
- };
1709
- for (let i = 1; i < members.length; i++) {
1710
- const previous = members[i - 1];
1711
- const current = members[i];
1712
- if (previous < current) continue;
1713
- return {
1714
- kind: "members-unordered",
1715
- subject,
1716
- message: `SliceRecord ${subject}: members[] is not in strictly ascending order at index ${i} ("${current}" follows "${previous}"), so members[0] is not necessarily the anchor (slice-view.md §8.2 for the order, §11.1 for uniqueness).`
1717
- };
1718
- }
1719
- const expected = sliceIdFor(anchor);
1720
- if (id !== expected) return {
1721
- kind: "id-not-derived",
1722
- subject,
1723
- message: `SliceRecord id "${id}" is not derived from the anchor "${anchor}"; expected "${expected}" (slice-view.md §7.1).`
1724
- };
1725
- return null;
1726
- }
1727
- /** The reserved token an id opens with, or `null`. Shares the list `@aburi/core` enforces. */
1728
- function reservedNamespaceOf(id) {
1729
- const colon = id.indexOf(":");
1730
- if (colon < 0) return null;
1731
- const token = id.slice(0, colon);
1732
- return RESERVED_LANGUAGE_IDS.has(token) ? token : null;
1733
- }
1734
- /** Single source of the empty-`members[]` wording, shared with `sliceAnchor`. */
1735
- function emptyMembersViolation(subject) {
1736
- return {
1737
- kind: "members-empty",
1738
- subject,
1739
- message: `SliceRecord ${subject}: members[] is empty, so the Slice has no anchor (slice-view.md §11.1 requires at least one member).`
1740
- };
1741
- }
1742
- /**
1743
- * Throwing form of `sliceRecordViolation`. Raised as a coded `DiffError` so
1744
- * callers branch on `code` rather than parsing the message; which clause broke
1745
- * is `SliceRecordViolation.kind`, for callers that need to tell them apart.
1746
- */
1747
- function assertSliceRecordInvariant(record) {
1748
- const violation = sliceRecordViolation(record);
1749
- if (violation === null) return;
1750
- throw new DiffError(violation.message, {
1751
- code: "slice-invariant-violated",
1752
- value: violation.subject
1753
- });
1754
- }
1755
- /**
1756
- * §4.1 Node selection: keep every SymbolChange whose status is
1757
- * added / removed / changed / moved+changed / dropped-toggled. The identity
1758
- * is the head-side Symbol id where present (added / changed / moved+changed /
1759
- * dropped-toggled), the base-side id for removed. Pure moved and unchanged
1760
- * (already stripped upstream) are excluded — §4.1 / §4.3.
1761
- *
1762
- * The returned array is deliberately not deduplicated: a well-formed
1763
- * SymbolChange list from `buildDiff` never mentions the same Symbol id
1764
- * twice, and Union-Find would coalesce any accidental duplicate anyway.
1765
- */
1766
- function collectNodeIds(changes) {
1767
- const ids = [];
1768
- for (const change of changes) {
1769
- const id = nodeIdOf(change);
1770
- if (id === null) continue;
1771
- ids.push(id);
1772
- }
1773
- return ids;
1774
- }
1775
- function nodeIdOf(change) {
1776
- switch (change.status) {
1777
- case "added": return change.symbol.id;
1778
- case "removed": return change.symbol.id;
1779
- case "unknown": return change.symbol.id;
1780
- case "changed":
1781
- case "moved+changed":
1782
- case "dropped-toggled": return change.after.id;
1783
- case "moved": return null;
1784
- }
1785
- }
1786
- /**
1787
- * §5.1 Edge selection: union of base and head, restricted to edges whose
1788
- * BOTH endpoints are Nodes, canonicalised to `(u, v)` with `u < v` (self-
1789
- * loops implicitly dropped). Multi-edges collapse naturally inside the WCC
1790
- * primitive — no explicit dedup needed.
1791
- *
1792
- * The union is the load-bearing rule of §5.3: a controller that called an
1793
- * old service in base and a new service in head needs both edges to land
1794
- * all three Symbols in a single Slice.
1795
- */
1796
- function collectEdges(baseEdges, headEdges, nodeIds) {
1797
- const pairs = [];
1798
- for (const edge of baseEdges) appendEdgeIfBothNodes(edge, nodeIds, pairs);
1799
- for (const edge of headEdges) appendEdgeIfBothNodes(edge, nodeIds, pairs);
1800
- return pairs;
1801
- }
1802
- function appendEdgeIfBothNodes(edge, nodeIds, out) {
1803
- if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) return;
1804
- out.push([edge.from, edge.to]);
1805
- }
1806
-
1807
- //#endregion
1808
- //#region src/status.ts
1809
- /**
1810
- * §4 — determine which of the five statuses a matched pair falls into. `dropped-toggled`
1811
- * has absolute priority: whenever `dropped` differs between base and head, every other
1812
- * signal is ignored because the underlying fingerprint change is a consequence of the
1813
- * drop-rule flip, not a genuine code edit (§4.1 rationale).
1814
- *
1815
- * "pathChanged" catches both physical file relocation AND in-file rename: any Symbol id
1816
- * mismatch between base and head means the identifier moved, and DF9 ("method rename
1817
- * (same file, same logic) → moved") relies on this. Stage-1 pairs cannot trigger the id branch
1818
- * (they match by exact id), so this only affects stage-2+ pairings where the pair was
1819
- * discovered via fingerprint or name/signature similarity.
1820
- */
1821
- function classifyStatus(base, head) {
1822
- if (base.dropped !== head.dropped) return "dropped-toggled";
1823
- const pathChanged = base.source.file !== head.source.file || base.id !== head.id;
1824
- const fingerprintChanged = base.fingerprint.api !== head.fingerprint.api || base.fingerprint.logic !== head.fingerprint.logic || base.fingerprint.syntax !== head.fingerprint.syntax;
1825
- if (pathChanged && fingerprintChanged) return "moved+changed";
1826
- if (pathChanged) return "moved";
1827
- if (fingerprintChanged) return "changed";
1828
- return "unchanged";
1829
- }
1830
- function dropDirection(head) {
1831
- return head.dropped ? "to-dropped" : "to-kept";
1832
- }
1833
-
1834
- //#endregion
1835
- //#region src/diff.ts
1836
- const DIFF_SCHEMA = "https://aburi.kage1020.com/schema/aburi.diff.v1.json";
1837
- const DEFAULT_GENERATOR = {
1838
- name: "aburi",
1839
- version: "0.0.0"
1840
- };
1841
- /**
1842
- * Top-level entry: run the 5-stage matcher, classify each pair, produce array deltas,
1843
- * fold in Component / Dependency diffs, and assemble the `aburi.diff.v1` JSON projection.
1844
- * The function is pure; write-to-disk is delegated to `writeCanonicalDiff` so callers can
1845
- * pipe the result through additional steps (Markdown projection, `--fail-on` gate) before
1846
- * serialisation.
1847
- */
1848
- function buildDiff(input) {
1849
- assertDiffable(input.baseIR, "baseIR");
1850
- assertDiffable(input.headIR, "headIR");
1851
- ensureSchemasAgree(input.baseIR, input.headIR);
1852
- const stage1 = matchStageId(input.baseIR.symbols, input.headIR.symbols);
1853
- const stage2 = matchStageGitRename(stage1.remainingBase, stage1.remainingHead, input.gitRenames ?? null);
1854
- const stage3 = matchStageLogicFingerprint(stage2.remainingBase, stage2.remainingHead);
1855
- const stage4 = matchStageNameSignature(stage3.remainingBase, stage3.remainingHead);
1856
- const stage4_5 = matchStageDroppedWeak(stage4.remainingBase, stage4.remainingHead);
1857
- const pairs = [
1858
- ...stage1.matched,
1859
- ...stage2.matched,
1860
- ...stage3.matched,
1861
- ...stage4.matched,
1862
- ...stage4_5.matched
1863
- ];
1864
- const summary = {
1865
- unknown: 0,
1866
- depsUnknown: 0,
1867
- added: 0,
1868
- removed: 0,
1869
- moved: 0,
1870
- movedChanged: 0,
1871
- changed: 0,
1872
- droppedToggled: 0,
1873
- unchanged: 0,
1874
- droppedAdded: 0,
1875
- droppedRemoved: 0,
1876
- componentsAdded: 0,
1877
- componentsRemoved: 0,
1878
- componentsChanged: 0,
1879
- depsAdded: 0,
1880
- depsRemoved: 0
1881
- };
1882
- let unknown = 0;
1883
- const symbols = [];
1884
- for (const pair of pairs) {
1885
- const status = classifyStatus(pair.base, pair.head);
1886
- if (status === "unchanged") {
1887
- summary.unchanged++;
1888
- continue;
1889
- }
1890
- if (status === "dropped-toggled") {
1891
- summary.droppedToggled++;
1892
- symbols.push({
1893
- status: "dropped-toggled",
1894
- before: pair.base,
1895
- after: pair.head,
1896
- direction: dropDirection(pair.head)
1897
- });
1898
- continue;
1899
- }
1900
- if (status === "moved") {
1901
- summary.moved++;
1902
- symbols.push({
1903
- status: "moved",
1904
- before: pair.base,
1905
- after: pair.head,
1906
- rationale: pair.rationale
1907
- });
1908
- continue;
1909
- }
1910
- if (status === "changed") {
1911
- summary.changed++;
1912
- symbols.push({
1913
- status: "changed",
1914
- before: pair.base,
1915
- after: pair.head,
1916
- delta: computeSymbolDelta(pair.base, pair.head, input.delta ?? {})
1917
- });
1918
- continue;
1919
- }
1920
- summary.movedChanged++;
1921
- symbols.push({
1922
- status: "moved+changed",
1923
- before: pair.base,
1924
- after: pair.head,
1925
- rationale: pair.rationale,
1926
- delta: computeSymbolDelta(pair.base, pair.head, input.delta ?? {})
1927
- });
1928
- }
1929
- const finalRemainingHead = stage4_5.remainingHead;
1930
- const finalRemainingBase = stage4_5.remainingBase;
1931
- const baseSide = dependencySideView(input.baseIR);
1932
- const headSide = dependencySideView(input.headIR);
1933
- const lostByHead = headSide.lostFiles;
1934
- const lostByBase = baseSide.lostFiles;
1935
- for (const h of finalRemainingHead) {
1936
- if (h.dropped) {
1937
- summary.droppedAdded++;
1938
- continue;
1939
- }
1940
- const reason = lostByBase.get(h.source.file);
1941
- if (reason !== void 0) {
1942
- unknown++;
1943
- symbols.push({
1944
- status: "unknown",
1945
- symbol: h,
1946
- absentFrom: "base",
1947
- reason
1948
- });
1949
- continue;
1950
- }
1951
- summary.added++;
1952
- symbols.push({
1953
- status: "added",
1954
- symbol: h
1955
- });
1956
- }
1957
- for (const b of finalRemainingBase) {
1958
- if (b.dropped) {
1959
- summary.droppedRemoved++;
1960
- continue;
1961
- }
1962
- const reason = lostByHead.get(b.source.file);
1963
- if (reason !== void 0) {
1964
- unknown++;
1965
- symbols.push({
1966
- status: "unknown",
1967
- symbol: b,
1968
- absentFrom: "head",
1969
- reason
1970
- });
1971
- continue;
1972
- }
1973
- summary.removed++;
1974
- symbols.push({
1975
- status: "removed",
1976
- symbol: b
1977
- });
1978
- }
1979
- const components = diffComponents(input.baseIR.components, input.headIR.components);
1980
- summary.componentsAdded = components.added.length;
1981
- summary.componentsRemoved = components.removed.length;
1982
- summary.componentsChanged = components.changed.length;
1983
- const dependencies = diffDependencies(input.baseIR.dependencies, input.headIR.dependencies, {
1984
- base: baseSide,
1985
- head: headSide
1986
- });
1987
- summary.depsAdded = dependencies.added.length;
1988
- summary.depsRemoved = dependencies.removed.length;
1989
- summary.depsUnknown = dependencies.unknown.length;
1990
- summary.unknown = unknown;
1991
- symbols.sort(compareSymbolChange);
1992
- const slices = computeSlices({
1993
- changes: symbols,
1994
- baseCallEdges: reconstructCallEdgesFromIR(input.baseIR),
1995
- headCallEdges: reconstructCallEdgesFromIR(input.headIR)
1996
- });
1997
- return {
1998
- $schema: DIFF_SCHEMA,
1999
- generator: input.generator ?? DEFAULT_GENERATOR,
2000
- base: input.base,
2001
- head: input.head,
2002
- summary,
2003
- symbols,
2004
- components,
2005
- dependencies,
2006
- slices,
2007
- notCompared: filesNeitherSideRead(lostByBase, lostByHead)
2008
- };
2009
- }
2010
- /**
2011
- * Paths both documents record as never analysed, with each side's own reason.
2012
- *
2013
- * This is the loss `unknown` cannot describe. That status is derived from the matcher's
2014
- * leftovers — a Symbol one document holds and the other lacks — and a file skipped on both
2015
- * sides contributes Symbols to neither, so it leaves no leftover to classify and the whole
2016
- * document falls silent about it. Silence is the one thing it must not do here: a diff with
2017
- * nothing to say about a path is exactly what a diff that compared it and found it unchanged
2018
- * looks like.
2019
- *
2020
- * The intersection, not the union. A one-sided loss has leftovers on the other side and is
2021
- * reported as `unknown` there; listing it here as well would count one loss twice, in two
2022
- * vocabularies that mean different things.
2023
- *
2024
- * Always returns an array, empty included. Optionality in the schema covers documents written
2025
- * before the field existed, and no arithmetic elsewhere in a diff would let a reader tell that
2026
- * case from a run that missed nothing (docs/design/diff-algorithm.md §10.1).
2027
- */
2028
- function filesNeitherSideRead(lostByBase, lostByHead) {
2029
- const both = [];
2030
- for (const [path, baseReason] of lostByBase) {
2031
- const headReason = lostByHead.get(path);
2032
- if (headReason === void 0) continue;
2033
- both.push({
2034
- path,
2035
- baseReason,
2036
- headReason
2037
- });
2038
- }
2039
- return both.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2040
- }
2041
- /** §9.1 — refuse to diff across schema versions. */
2042
- function ensureSchemasAgree(base, head) {
2043
- 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.`, {
2044
- code: "schema-mismatch",
2045
- value: base.$schema
2046
- });
2047
- }
2048
- /** Identity is a single field, so joining the one-member tuple is joining nothing. */
2049
- const soleField = (parts) => parts.join("");
2050
- const IDENTIFIED_COLLECTIONS = [
2051
- {
2052
- field: "symbols",
2053
- identityFields: ["id"],
2054
- keyOf: soleField,
2055
- noun: "id",
2056
- show: soleField,
2057
- consequence: "stage 1 pairs Symbols by id and every later stage tracks the base Symbols it has consumed by id, so a repeat leaves one entry out of the diff entirely or classifies its counterpart twice (ir-schema.md §14 #1)"
2058
- },
2059
- {
2060
- field: "components",
2061
- identityFields: ["id"],
2062
- keyOf: soleField,
2063
- noun: "id",
2064
- show: soleField,
2065
- consequence: "Component identity is the id, so a repeat hides one entry and can report a change the two revisions do not contain (ir-schema.md §14 #2)"
2066
- },
2067
- {
2068
- field: "dependencies",
2069
- identityFields: DEPENDENCY_IDENTITY_FIELDS,
2070
- keyOf: dependencyIdentity,
2071
- noun: "(from, to, via) triple",
2072
- show: (parts) => `(${parts.join(", ")})`,
2073
- consequence: "direction and effect are deliberately outside Dependency identity (§6.2), so a repeat surfaces as an added + removed pair no reader can tell from a real flip (ir-schema.md §14 #13)"
2074
- }
2075
- ];
2076
- /**
2077
- * The two things `buildDiff` needs before stage 1 runs: a Document it can walk, and
2078
- * identities it can key on. diff-algorithm.md §3.7 states the second and why it is checked
2079
- * here as well as at extraction time.
2080
- *
2081
- * Neither half is a full schema validation. A Symbol that reaches stage 1 is an object with
2082
- * a string `id`, because the identity scan reads that much; nothing checks it carries a
2083
- * `fingerprint`, which is `checkIRIntegrity` #20's job and runs when the CLI reads an IR off
2084
- * disk. What this does buy is that a malformed collection is named — `symbols: undefined`
2085
- * used to surface as `TypeError: undefined is not iterable` and `symbols: [null]` as
2086
- * `TypeError: Cannot read properties of null (reading 'id')`, both from inside
2087
- * `matchStageId`, with neither the collection nor the index named.
2088
- */
2089
- function assertDiffable(ir, name) {
2090
- if (ir === null || typeof ir !== "object") throw shapeError(name, `${name} must be an IR object; got ${describeValue(ir)}.`);
2091
- if (typeof ir.$schema !== "string" || ir.$schema.length === 0) throw shapeError(`${name}.$schema`, `${name}.$schema must be a non-empty schema URL.`);
2092
- for (const collection of IDENTIFIED_COLLECTIONS) assertUniqueIdentity(ir[collection.field], `${name}.${collection.field}`, collection);
2093
- }
2094
- function assertUniqueIdentity(entries, subject, collection) {
2095
- if (!Array.isArray(entries)) throw shapeError(subject, `${subject} must be an array.`);
2096
- const firstSeen = /* @__PURE__ */ new Map();
2097
- for (const [index, entry] of entries.entries()) {
2098
- const entrySubject = `${subject}[${index}]`;
2099
- const parts = identityFieldsOf(entry, entrySubject, collection);
2100
- const key = collection.keyOf(parts);
2101
- const first = firstSeen.get(key);
2102
- if (first === void 0) {
2103
- firstSeen.set(key, index);
2104
- continue;
2105
- }
2106
- const shown = collection.show(parts);
2107
- throw new DiffError(`${entrySubject} repeats the ${collection.noun} "${shown}" first seen at index ${first}; ${collection.consequence}.`, {
2108
- code: "ir-identity-collision",
2109
- value: shown
2110
- });
2111
- }
2112
- }
2113
- /**
2114
- * The identity fields of one entry, established as strings on the way out. Reading them is
2115
- * what forces the check: without it a Symbol carrying no `id` has nothing to collide with,
2116
- * passes, and derives a Slice anchored on `undefined` several stages later — which
2117
- * `assertSliceRecordInvariant` reports as `slice-invariant-violated`, the one code the CLI
2118
- * presents as a bug in Aburi rather than in the caller's IR.
2119
- */
2120
- function identityFieldsOf(entry, subject, collection) {
2121
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw shapeError(subject, `${subject} must be an object; got ${describeValue(entry)}.`);
2122
- const parts = [];
2123
- for (const field of collection.identityFields) {
2124
- const value = entry[field];
2125
- if (typeof value !== "string") throw shapeError(`${subject}.${field}`, `${subject}.${field} must be a string; got ${describeValue(value)}.`);
2126
- parts.push(value);
2127
- }
2128
- return parts;
2129
- }
2130
- function shapeError(subject, message) {
2131
- return new DiffError(message, {
2132
- code: "ir-shape-invalid",
2133
- value: subject
2134
- });
2135
- }
2136
- function describeValue(value) {
2137
- if (value === void 0) return "undefined";
2138
- if (value === null) return "null";
2139
- if (Array.isArray(value)) return "an array";
2140
- return `a ${typeof value}`;
2141
- }
2142
- /**
2143
- * Deterministic ordering of the `symbols[]` output:
2144
- * 1. by status (alphabetical) — pins added/changed/moved sections
2145
- * 2. by the "reference" id (`after` for change/move/toggle, otherwise `symbol`)
2146
- *
2147
- * The result is stable byte-for-byte for equal inputs, matching the canonicalisation
2148
- * guarantee the diff JSON must uphold when persisted to `out/diff.json`.
2149
- */
2150
- function compareSymbolChange(a, b) {
2151
- if (a.status !== b.status) return a.status < b.status ? -1 : 1;
2152
- const idA = referenceId(a);
2153
- const idB = referenceId(b);
2154
- return idA < idB ? -1 : idA > idB ? 1 : 0;
2155
- }
2156
- function referenceId(change) {
2157
- if (change.status === "added" || change.status === "removed" || change.status === "unknown") return change.symbol.id;
2158
- return change.after.id;
2159
- }
2160
- /**
2161
- * The files a document's scan never analysed, by path, with why.
2162
- *
2163
- * `stats.skippedFiles` is Class B: absent means the writer predates the field, not that
2164
- * nothing was lost. An empty map is the honest answer either way — with no enumeration
2165
- * there is no absence this function can explain, and guessing from
2166
- * `totalFiles > parsedFiles` would attach a reason to whichever Symbols happened to be
2167
- * missing. The CLI says so on stderr instead; a diff cannot invent the list.
2168
- */
2169
- /**
2170
- * Byte-deterministic serialiser for a DiffResult. Delegates to `@aburi/core`
2171
- * `serializeCanonical` so the sort order, NFC normalisation, and codepoint key sort are
2172
- * shared with the IR side.
2173
- */
2174
- function writeCanonicalDiff(diff, options = {}) {
2175
- return serializeCanonical(diff, options);
2176
- }
2177
-
2178
- //#endregion
2179
- export { DEFAULT_LINE_FUZZ, DiffError, MAX_LINE_FUZZ, MIN_LINE_FUZZ, assertSliceRecordInvariant, buildDiff, classifyStatus, computeSlices, computeSymbolDelta, dependencySideView, diffComponents, diffDependencies, dropDirection, jaccard, jaccardTokens, lastSegment, matchStageDroppedWeak, matchStageGitRename, matchStageId, matchStageLogicFingerprint, matchStageNameSignature, memberSimilarity, nameSimilarity, ownersAreCompatible, signatureSimilarity, sliceAnchor, sliceRecordViolation, tokenizeName, writeCanonicalDiff };
2180
- //# sourceMappingURL=index.mjs.map
1
+ import{DOCUMENT_SUBJECT as e,RESERVED_LANGUAGE_IDS as t,checkDocumentShape as n,computeWeaklyConnectedComponents as r,reconstructCallEdgesFromIR as i,serializeCanonical as a,trySymbolId as o}from"@aburi/core";var s=class extends Error{code;value;violations;constructor(e,t,n){super(e,n),this.name=`DiffError`,this.code=t.code,this.value=t.value,this.violations=t.violations}};function c(e,t){let n=new Map;for(let t of e)n.set(t.id,t);let r=new Map;for(let e of t)r.set(e.id,e);let i=[],a=[],o=[];for(let[e,t]of r){let r=n.get(e);if(r===void 0){i.push(t);continue}g(r,t)||o.push({before:r,after:t,delta:{rootsChanged:!b(r.roots,t.roots),publicApiChanged:!b(r.publicApi??[],t.publicApi??[]),frameworksChanged:!b(r.frameworks??[],t.frameworks??[])}})}for(let[e,t]of n)r.has(e)||a.push(t);return x(i),x(a),o.sort((e,t)=>e.after.id<t.after.id?-1:+(e.after.id>t.after.id)),{added:i,removed:a,changed:o}}function l(e){let t=new Map;for(let n of e.symbols)t.set(n.id,n.source.file);let n=new Map;for(let t of e.stats.skippedFiles??[])n.set(t.path,t.reason);return{symbolFiles:t,lostFiles:n}}function u(e,t,n){let r=new Map;for(let t of e)r.set(m(t),t);let i=new Map;for(let e of t)i.set(m(e),e);let a=[],o=[],s=[];for(let[e,t]of i){let i=r.get(e);if(i===void 0){let e=d(t,n.head,n.base);e.length>0?s.push({dependency:t,absentFrom:`base`,lostFiles:e}):a.push(t);continue}(i.direction!==t.direction||(i.effect??null)!==(t.effect??null))&&(o.push(i),a.push(t))}for(let[e,t]of r){if(i.has(e))continue;let r=d(t,n.base,n.head);r.length>0?s.push({dependency:t,absentFrom:`head`,lostFiles:r}):o.push(t)}return a.sort(h),o.sort(h),s.sort((e,t)=>h(e.dependency,t.dependency)),{added:a,removed:o,unknown:s}}function d(e,t,n){let r=new Map;for(let i of[e.from,e.to]){let e=t.symbolFiles.get(i);if(e===void 0)continue;let a=n.lostFiles.get(e);a!==void 0&&r.set(e,a)}return[...r.entries()].map(([e,t])=>({path:e,reason:t})).sort((e,t)=>e.path<t.path?-1:+(e.path>t.path))}const f=[`from`,`to`,`via`];function p(e){return e.join(`::`)}function m(e){return p(f.map(t=>e[t]))}function h(e,t){let n=m(e),r=m(t);return n<r?-1:+(n>r)}function g(e,t){return _(e,`base`)===_(t,`head`)}function _(e,t){try{return a(v(e),{format:`compact`})}catch(n){throw new s(`${t} components[id=${e.id}] cannot be compared: ${n instanceof Error?n.message:String(n)}`,{code:`ir-shape-invalid`,value:`components[id=${e.id}]`},{cause:n})}}function v(e){let t={...e};(t.description===null||t.description===void 0)&&delete t.description;for(let e of y){let n=t[e];(n===void 0||Array.isArray(n)&&n.length===0)&&delete t[e]}return t}const y=[`publicApi`,`frameworks`];function b(e,t){if(e.length!==t.length)return!1;for(let n=0;n<e.length;n++)if(e[n]!==t[n])return!1;return!0}function x(e){e.sort((e,t)=>e.id<t.id?-1:+(e.id>t.id))}const S=2,ee=10,te=0;function C(e,t,n={}){let r=ne(n.lineFuzz??2),i={apiChanged:e.fingerprint.api!==t.fingerprint.api,logicChanged:e.fingerprint.logic!==t.fingerprint.logic,syntaxChanged:e.fingerprint.syntax!==t.fingerprint.syntax,componentChanged:(e.component??null)!==(t.component??null),visibilityChanged:e.visibility!==t.visibility};return i.rules=ae(e.rules,t.rules,r),i.effects=se(e.effects,t.effects),i.calls=le(e.calls,t.calls,r),i.decorators=de(e.decorators,t.decorators,r),i.signature=pe(e.signature??null,t.signature??null),i}function ne(e){if(!Number.isFinite(e)||!Number.isInteger(e))throw new s(`config.diff.lineFuzz must be an integer in [0, 10]; got ${String(e)}.`,{code:`invalid-line-fuzz`,value:String(e)});if(e<0||e>10)throw new s(`config.diff.lineFuzz must be within [0, 10]; got ${e}.`,{code:`invalid-line-fuzz`,value:String(e)});return e}function w(e,t,n,r){let i=new Set(e.map((e,t)=>t)),a=new Set(t.map((e,t)=>t)),o=new Map;for(let s of[!0,!1]){let c=(e,t)=>e.key===t.key&&Math.abs(e.line-t.line)<=r&&(!s||n(e.item,t.item));for(let[n,r]of ie(e,t,i,a,c)){i.delete(n),a.delete(r);let t=e[n];t!==void 0&&o.set(r,t)}}let s=[],c=[];for(let[e,r]of t.entries()){let t=o.get(e);t===void 0?s.push(r.item):n(t.item,r.item)||c.push(r.item)}return{added:s,removed:e.filter((e,t)=>i.has(t)).map(e=>e.item),modified:c}}const T={pairs:0,distance:0};function re(e,t){return e.pairs===t.pairs?e.distance<t.distance:e.pairs>t.pairs}function ie(e,t,n,r,i){let a=Array.from({length:e.length+1},()=>Array.from({length:t.length+1},()=>T)),o=(o,s)=>{let c=e[o],l=t[s];if(c===void 0||l===void 0||!n.has(o)||!r.has(s)||!i(c,l))return null;let u=a[o+1]?.[s+1]??T;return{pairs:u.pairs+1,distance:u.distance+Math.abs(c.line-l.line)}};for(let n=e.length-1;n>=0;n--)for(let e=t.length-1;e>=0;e--){let t=a[n+1]?.[e]??T,r=a[n]?.[e+1]??T,i=re(t,r)?t:r,s=o(n,e);s!==null&&re(s,i)&&(i=s);let c=a[n];c!==void 0&&(c[e]=i)}let s=[],c=0,l=0;for(;c<e.length&&l<t.length;){let e=a[c]?.[l]??T,t=o(c,l);if(t!==null&&t.pairs===e.pairs&&t.distance===e.distance){s.push([c,l]),c++,l++;continue}let n=a[c+1]?.[l]??T;n.pairs===e.pairs&&n.distance===e.distance?c++:l++}return s}function ae(e,t,n){let r=e=>({item:e,key:e.type,line:e.line});return w(e.map(r),t.map(r),oe,n)}function oe(e,t){return e.type===t.type&&(e.condition??null)===(t.condition??null)&&(e.what??null)===(t.what??null)&&(e.expr??null)===(t.expr??null)&&(e.loopKind??null)===(t.loopKind??null)}function se(e,t){let n=e=>({item:e,key:`${e.id}::${e.target}`,line:e.line??0});return w(e.map(n),t.map(n),ce,1/0)}function ce(e,t){return e.id===t.id&&e.target===t.target&&e.plugin===t.plugin&&e.confidence===t.confidence}function le(e,t,n){let r=e=>({item:e,key:e.target,line:e.line});return w(e.map(r),t.map(r),ue,n)}function ue(e,t){return e.target===t.target&&(e.resolved??null)===(t.resolved??null)}function de(e,t,n){let r=e=>({item:e,key:e.name,line:e.line});return w(e.map(r),t.map(r),fe,n)}function fe(e,t){if(e.name!==t.name||e.arguments.length!==t.arguments.length)return!1;for(let n=0;n<e.arguments.length;n++)if(e.arguments[n]!==t.arguments[n])return!1;return!0}function pe(e,t){if(e===null&&t===null)return null;if(e===null||t===null){let n={added:[],removed:[],modified:[]},r=e??t;if(r===null)return null;let i=e===null;return{inputs:i?{added:[...r.inputs],removed:[],modified:[]}:{added:[],removed:[...r.inputs],modified:[]},outputs:i?{added:[...r.outputs],removed:[],modified:[]}:{added:[],removed:[...r.outputs],modified:[]},throws:i?{added:[...r.throws],removed:[],modified:[]}:{added:[],removed:[...r.throws],modified:[]},asyncChanged:(e?.async??!1)!==(t?.async??!1),generatorChanged:(e?.generator??!1)!==(t?.generator??!1),typeParametersChanged:!ge(e?.typeParameters??n.added.map(String),t?.typeParameters??n.added.map(String))}}let n=(e,t)=>({item:e,key:`${t}:${e.name}`,line:t});return{inputs:w(e.inputs.map(n),t.inputs.map(n),(e,t)=>e.name===t.name&&e.type===t.type,0),outputs:me(e.outputs,t.outputs),throws:he(e.throws,t.throws),asyncChanged:e.async!==t.async,generatorChanged:e.generator!==t.generator,typeParametersChanged:!ge(e.typeParameters,t.typeParameters)}}function me(e,t){let n=[],r=[],i=Math.max(e.length,t.length);for(let a=0;a<i;a++){let i=a<e.length?e[a]:void 0,o=a<t.length?t[a]:void 0;i===void 0&&o!==void 0?n.push(o):o===void 0&&i!==void 0?r.push(i):i!==o&&(i!==void 0&&r.push(i),o!==void 0&&n.push(o))}return{added:n,removed:r,modified:[]}}function he(e,t){let n=new Set(e),r=new Set(t),i=[],a=[];for(let e of r)n.has(e)||i.push(e);for(let e of n)r.has(e)||a.push(e);return{added:i,removed:a,modified:[]}}function ge(e,t){if(e.length!==t.length)return!1;for(let n=0;n<e.length;n++)if(e[n]!==t[n])return!1;return!0}function E(e,t){let n=e??null,r=t??null;if(n===null&&r===null)return 1;if(n===null||r===null)return 0;let i=D(n.inputs.map(e=>e.type),r.inputs.map(e=>e.type)),a=D(n.outputs,r.outputs),o=_e(n.throws,r.throws);return(i+a+o)/3}function D(e,t){if(e.length===0&&t.length===0)return 1;if(e.length!==t.length)return 0;let n=0;for(let r=0;r<e.length;r++)e[r]===t[r]&&n++;return n/e.length}function _e(e,t){if(e.length===0&&t.length===0)return 1;let n=new Set(e),r=new Set(t),i=0;for(let e of n)r.has(e)&&i++;let a=n.size+r.size-i;return a===0?1:i/a}function O(e){let t=new Set,n=[];for(let r of e.split(/[.:_\-\s/]+/))if(r.length!==0)for(let e of ve(r)){let r=e.toLowerCase();r.length===0||t.has(r)||(t.add(r),n.push(r))}return n}function ve(e){let t=[],n=``;for(let r of e){if(n===``){n=r;continue}ye(n.slice(-1),r)?(t.push(n),n=r):n+=r}return n.length>0&&t.push(n),t}function ye(e,t){let n=e>=`a`&&e<=`z`,r=e>=`A`&&e<=`Z`,i=e>=`0`&&e<=`9`,a=t>=`a`&&t<=`z`,o=t>=`A`&&t<=`Z`;return!!(n&&o||(n||r)&&t>=`0`&&t<=`9`||i&&(a||o))}function be(e,t){return k(new Set(e),new Set(t))}function k(e,t){if(e.size===0&&t.size===0)return 1;if(e.size===0||t.size===0)return 0;let[n,r]=e.size<=t.size?[e,t]:[t,e],i=0;for(let e of n)r.has(e)&&i++;return i/(e.size+t.size-i)}function xe(e,t){return be(O(e),O(t))}const A=e=>new Set(O(e));function Se(e,t){return j(A,e,t)}function Ce(e,t){return M(A,e,t)}function we(e,t){return De(A,e,t)}function Te(e,t){if(e===t)return!0;let[n,r]=e.length<=t.length?[e,t]:[t,e];return Ee(n,r)}function Ee(e,t){return t===`${e}s`||t===`${e}es`?!0:e.endsWith(`y`)&&t===`${e.slice(0,-1)}ies`}function j(e,t,n){return k(e(t),e(n))}function M(e,t,n){return k(e(R(t)),e(R(n)))}function De(e,t,n){let r=L(t),i=L(n);return r===``&&i===``?!0:r===``||i===``?!1:r===i?!0:N(r,i,e)}function N(e,t,n){let r=e.split(`.`),i=t.split(`.`);return r.length===i.length?r.every((e,t)=>{let r=i[t];return r===void 0?!1:e===r?!0:Oe(n(e),n(r))}):!1}function Oe(e,t){if(e.size!==t.size||e.size>32)return!1;let n=[...t],r=new Map;for(let t of e)if(!P(t,n,r,new Set))return!1;return!0}function P(e,t,n,r){for(let i of t){if(r.has(i)||!Te(e,i))continue;r.add(i);let a=n.get(i);if(a===void 0||P(a,t,n,r))return n.set(i,e),!0}return!1}function ke(e,t,n){let r=e.slice(0,F(e)),i=t.slice(0,F(t));if(r===i)return!0;let a=n(r),o=n(i);if(a.size!==o.size||a.size>32)return!1;for(let e of a){let t=!1;for(let n of o)if(Te(e,n)){t=!0;break}if(!t)return!1}return!0}function F(e){let t=e.indexOf(`.`);return t<0?e.length:t}function I(){let e=new Map,t=t=>{let n=e.get(t);if(n!==void 0)return n;let r=new Set(O(t));return e.set(t,r),r},n=new Map,r=e=>{let t=n.get(e);if(t!==void 0)return t;let r=L(e);return n.set(e,r),r};return{name:(e,n)=>j(t,e,n),member:(e,n)=>M(t,e,n),ownersCompatible:(e,n)=>{let i=r(e),a=r(n);return i===a?!0:i===``||a===``||!ke(i,a,t)?!1:N(i,a,t)}}}function L(e){let t=e.indexOf(`::`);if(t>=0)return e.slice(0,t);let n=e.lastIndexOf(`.`);return n>=0?e.slice(0,n):``}function R(e){let t=e.indexOf(`::`);if(t>=0)return e.slice(t+2);let n=e.lastIndexOf(`.`);return n>=0?e.slice(n+1):e}function z(e){e.sort(Ae);let t=new Set,n=new Set,r=[];for(let i of e)t.has(i.base.id)||n.has(i.head.id)||(t.add(i.base.id),n.add(i.head.id),r.push(i));return{pairs:r,baseIds:t,headIds:n}}function Ae(e,t){return e.score===t.score?e.base.id===t.base.id?e.head.id<t.head.id?-1:+(e.head.id>t.head.id):e.base.id<t.base.id?-1:1:t.score-e.score}function B(e,t){return e.filter(e=>!t.has(e.id))}function V(e,t){let n=new Map;for(let e of t)n.set(e.id,e);let r=[],i=[],a=new Set;for(let t of e){let e=n.get(t.id);e===void 0?i.push(t):(r.push({base:t,head:e,rationale:`id-match`}),a.add(t.id))}return{matched:r,remainingBase:i,remainingHead:t.filter(e=>!a.has(e.id))}}function H(e,t,n){if(n===null||n.size===0)return{matched:[],remainingBase:[...e],remainingHead:[...t]};let r=new Map;for(let e of t)r.set(e.id,e);let i=new Map;for(let t of e){let e=n.get(t.source.file);if(e===void 0)continue;let a=Me(t.id,t.source.file,e);if(a===null)continue;let o=r.get(a);if(o===void 0)continue;let s=i.get(a);s===void 0?i.set(a,{head:o,bases:[t]}):s.bases.push(t)}let a=[],o=new Set,s=new Set;for(let{head:e,bases:t}of i.values()){let n=je(t);a.push({base:n,head:e,rationale:`git-rename`}),o.add(n.id),s.add(e.id)}return{matched:a,remainingBase:B(e,o),remainingHead:B(t,s)}}function je(e){let t=e[0];for(let n of e)n.id<t.id&&(t=n);return t}function Me(e,t,n){let r=e.indexOf(`:`),i=e.indexOf(`#`);return r<0||i<0||i<r||e.slice(r+1,i)!==t?e:o({language:e.slice(0,r),file:n,qualifiedName:e.slice(i+1)})}function U(e,t){let n=W(e),r=W(t),i=I(),a=[];for(let[e,t]of r){let r=n.get(e);r!==void 0&&a.push(...Ne(r,t,i))}let o=new Set(a.map(e=>e.base.id)),s=new Set(a.map(e=>e.head.id));return{matched:a,remainingBase:B(e,o),remainingHead:B(t,s)}}function W(e){let t=new Map;for(let n of e){if(n.dropped||n.fingerprint.logic===`000000000000`)continue;let e=t.get(n.fingerprint.logic)??[];e.push(n),t.set(n.fingerprint.logic,e)}return t}function Ne(e,t,n){let r=[...e],i=[...t],a=[];for(;r.length>0&&i.length>0;){let e=r.length===1?r[0]:void 0;if(e!==void 0){let t=Pe(e,i,n);if(t===void 0)break;a.push({base:e,head:t,rationale:`logic-fingerprint`});break}let t=[];for(let e of r)for(let r of i){let i=n.name(e.name,r.name);i>=.85&&t.push({base:e,head:r,score:i})}let o=z(t);if(o.pairs.length===0)break;for(let{base:e,head:t}of o.pairs)a.push({base:e,head:t,rationale:`logic-fingerprint+name-disambiguation`});r=B(r,o.baseIds),i=B(i,o.headIds)}return a}function Pe(e,t,n){let r;for(let i of t){let t=n.name(e.name,i.name);if(r===void 0||t>r.score){r={head:i,score:t};continue}t===r.score&&i.id<r.head.id&&(r={head:i,score:t})}return r?.head}function G(e,t){let n=new Map;for(let t of e){if(t.dropped||!J(t.name))continue;let e=Fe(t),r=n.get(e)??{members:[],byToken:new Map},i=r.members.length;r.members.push(t);for(let e of q(t.name)){let t=r.byToken.get(e)??[];t.push(i),r.byToken.set(e,t)}n.set(e,r)}let r=new Map;for(let e of n.values())r.set(e,new Int32Array(e.members.length));let i=I(),a=[],o=0;for(let e of t){if(e.dropped||e.signature===null||e.signature===void 0||!J(e.name))continue;let t=n.get(Fe(e));if(t===void 0)continue;let s=Re(e.name),c=Ie(s),l=q(e.name),u=0;for(let e of l)u+=t.byToken.get(e)?.length??0;if(u>=t.members.length){for(let n of t.members)K(n,e,i,c,s,a);continue}let d=r.get(t);if(d!==void 0){o++;for(let n of l)for(let r of t.byToken.get(n)??[]){if(d[r]===o)continue;d[r]=o;let n=t.members[r];n!==void 0&&K(n,e,i,c,s,a)}}}let s=z(a);return{matched:s.pairs.map(({base:e,head:t})=>({base:e,head:t,rationale:`name-signature`})),remainingBase:B(e,s.baseIds),remainingHead:B(t,s.headIds)}}function K(e,t,n,r,i,a){let o=n.member(e.name,t.name);if(o<r||!n.ownersCompatible(e.name,t.name))return;let s=.5*o+.3*E(e.signature??null,t.signature??null)+.2*Le;s>=i&&a.push({base:e,head:t,score:s})}function Fe(e){let t=e.signature===null||e.signature===void 0?`no-sig`:`has-sig`;return`${e.kind}::${t}`}function Ie(e){return 2*(e-.5)}function q(e){let t=O(R(e));return t.length===0?[` none`]:t}const Le=1;function Re(e){let t=O(R(e)).length;return t<=1?1:t===2?.95:.85}function J(e){return O(e).length>1}function Y(e,t){let n=e.filter(e=>e.dropped),r=t.filter(e=>e.dropped),i=[];for(let e of[Ue,We])i.push(...Ve(n,r,e));let a=ze(i);return{matched:a.map(({base:e,head:t})=>({base:e,head:t,rationale:`dropped-weak-match`})),remainingBase:B(e,new Set(a.map(e=>e.base.id))),remainingHead:B(t,new Set(a.map(e=>e.head.id)))}}function ze(e){let t=[...e].sort(Be),n=new Map,r=new Map;for(let[e,i]of t.entries())X(n,i.base.id,e),X(r,i.head.id,e);let i=e=>{let i=t[e];return i===void 0?[]:[...n.get(i.base.id)??[],...r.get(i.head.id)??[]].filter(t=>t!==e)},a=t.map(()=>!1),o=[],s=e=>{let n=e,r=!0;for(;!a[n];){a[n]=!0;let e=t[n];r&&e!==void 0&&o.push(e),r=!r;let s=i(n).find(e=>a[e]===!1);if(s===void 0)return;n=s}};for(let[e]of t.entries())!a[e]&&i(e).length<=1&&s(e);for(let[e]of t.entries())a[e]||s(e);return o}function Be(e,t){return e.base.id===t.base.id?e.head.id<t.head.id?-1:+(e.head.id>t.head.id):e.base.id<t.base.id?-1:1}function X(e,t,n){let r=e.get(t);r===void 0?e.set(t,[n]):r.push(n)}function Ve(e,t,n){let r=He(e,n),i=He(t,n),a=[];for(let[e,t]of r){let n=i.get(e);n!==void 0&&a.push({base:t,head:n})}return a}function He(e,t){let n=new Map,r=new Set;for(let i of e){let e=t(i);if(!r.has(e)){if(n.has(e)){n.delete(e),r.add(e);continue}n.set(e,i)}}return n}function Ue(e){return`${e.kind}/${R(e.name)}`}function We(e){return`${e.kind}/${Ge(e.source.file)}`}function Ge(e){let t=e.lastIndexOf(`/`);return t>=0?e.slice(t+1):e}function Ke(e){let t=$e(e.changes);if(t.length===0)return[];let n=new Set(t);return r(t,tt(e.baseCallEdges,e.headCallEdges,n),e=>e).map(Je)}function qe(e){return`slice:${e}`}function Je(e){let t=e[0];if(t===void 0)throw new s(`computeSlices: the clustering utility returned an empty component; every weakly-connected component contains at least the node that seeded it (slice-view.md §6).`,{code:`slice-invariant-violated`});let n={id:qe(t),members:[...e]};return Z(n),n}function Ye(e){let t=e.members[0];if(t===void 0){let t=Qe(e.id);throw new s(t.message,{code:`slice-invariant-violated`,value:t.subject})}return t}function Xe(e){if(typeof e!=`object`||!e)return{kind:`malformed-shape`,subject:`<not an object>`,message:`Expected a SliceRecord object; got ${e===null?`null`:typeof e}.`};let{id:t,members:n}=e,r=typeof t==`string`?t:`<missing id>`;if(typeof t!=`string`)return{kind:`malformed-shape`,subject:r,message:`SliceRecord ${r}: id must be a string; got ${typeof t}.`};if(!Array.isArray(n)||n.some(e=>typeof e!=`string`))return{kind:`malformed-shape`,subject:r,message:`SliceRecord ${r}: members must be an array of strings.`};let i=n[0];if(i===void 0)return Qe(r);let a=Ze(i);if(a!==null)return{kind:`anchor-in-reserved-namespace`,subject:r,message:`SliceRecord anchor "${i}" uses the reserved language token "${a}", so its Slice id would repeat the prefix (ir-schema.md §3.5, slice-view.md §7.5).`};for(let e=1;e<n.length;e++){let t=n[e-1],i=n[e];if(!(t<i))return{kind:`members-unordered`,subject:r,message:`SliceRecord ${r}: members[] is not in strictly ascending order at index ${e} ("${i}" follows "${t}"), so members[0] is not necessarily the anchor (slice-view.md §8.2 for the order, §11.1 for uniqueness).`}}let o=qe(i);return t===o?null:{kind:`id-not-derived`,subject:r,message:`SliceRecord id "${t}" is not derived from the anchor "${i}"; expected "${o}" (slice-view.md §7.1).`}}function Ze(e){let n=e.indexOf(`:`);if(n<0)return null;let r=e.slice(0,n);return t.has(r)?r:null}function Qe(e){return{kind:`members-empty`,subject:e,message:`SliceRecord ${e}: members[] is empty, so the Slice has no anchor (slice-view.md §11.1 requires at least one member).`}}function Z(e){let t=Xe(e);if(t!==null)throw new s(t.message,{code:`slice-invariant-violated`,value:t.subject})}function $e(e){let t=[];for(let n of e){let e=et(n);e!==null&&t.push(e)}return t}function et(e){switch(e.status){case`added`:return e.symbol.id;case`removed`:return e.symbol.id;case`unknown`:return e.symbol.id;case`changed`:case`moved+changed`:case`dropped-toggled`:return e.after.id;case`moved`:return null}}function tt(e,t,n){let r=[];for(let t of e)nt(t,n,r);for(let e of t)nt(e,n,r);return r}function nt(e,t,n){!t.has(e.from)||!t.has(e.to)||n.push([e.from,e.to])}function rt(e,t){if(e.dropped!==t.dropped)return`dropped-toggled`;let n=e.source.file!==t.source.file||e.id!==t.id,r=e.fingerprint.api!==t.fingerprint.api||e.fingerprint.logic!==t.fingerprint.logic||e.fingerprint.syntax!==t.fingerprint.syntax;return n&&r?`moved+changed`:n?`moved`:r?`changed`:`unchanged`}function it(e){return e.dropped?`to-dropped`:`to-kept`}const at={name:`aburi`,version:`0.0.0`};function ot(e){ut(e.baseIR,`baseIR`),ut(e.headIR,`headIR`),ct(e.baseIR,e.headIR);let t=V(e.baseIR.symbols,e.headIR.symbols),n=H(t.remainingBase,t.remainingHead,e.gitRenames??null),r=U(n.remainingBase,n.remainingHead),a=G(r.remainingBase,r.remainingHead),o=Y(a.remainingBase,a.remainingHead),s=[...t.matched,...n.matched,...r.matched,...a.matched,...o.matched],d={unknown:0,depsUnknown:0,added:0,removed:0,moved:0,movedChanged:0,changed:0,droppedToggled:0,unchanged:0,droppedAdded:0,droppedRemoved:0,componentsAdded:0,componentsRemoved:0,componentsChanged:0,depsAdded:0,depsRemoved:0},f=0,p=[];for(let t of s){let n=rt(t.base,t.head);if(n===`unchanged`){d.unchanged++;continue}if(n===`dropped-toggled`){d.droppedToggled++,p.push({status:`dropped-toggled`,before:t.base,after:t.head,direction:it(t.head)});continue}if(n===`moved`){d.moved++,p.push({status:`moved`,before:t.base,after:t.head,rationale:t.rationale});continue}if(n===`changed`){d.changed++,p.push({status:`changed`,before:t.base,after:t.head,delta:C(t.base,t.head,e.delta??{})});continue}d.movedChanged++,p.push({status:`moved+changed`,before:t.base,after:t.head,rationale:t.rationale,delta:C(t.base,t.head,e.delta??{})})}let m=o.remainingHead,h=o.remainingBase,g=l(e.baseIR),_=l(e.headIR),v=_.lostFiles,y=g.lostFiles;for(let e of m){if(e.dropped){d.droppedAdded++;continue}let t=y.get(e.source.file);if(t!==void 0){f++,p.push({status:`unknown`,symbol:e,absentFrom:`base`,reason:t});continue}d.added++,p.push({status:`added`,symbol:e})}for(let e of h){if(e.dropped){d.droppedRemoved++;continue}let t=v.get(e.source.file);if(t!==void 0){f++,p.push({status:`unknown`,symbol:e,absentFrom:`head`,reason:t});continue}d.removed++,p.push({status:`removed`,symbol:e})}let b=c(e.baseIR.components,e.headIR.components);d.componentsAdded=b.added.length,d.componentsRemoved=b.removed.length,d.componentsChanged=b.changed.length;let x=u(e.baseIR.dependencies,e.headIR.dependencies,{base:g,head:_});d.depsAdded=x.added.length,d.depsRemoved=x.removed.length,d.depsUnknown=x.unknown.length,d.unknown=f,p.sort(ht);let S=Ke({changes:p,baseCallEdges:i(e.baseIR),headCallEdges:i(e.headIR)});return{$schema:`https://aburi.kage1020.com/schema/aburi.diff.v1.json`,generator:e.generator??at,base:e.base,head:e.head,summary:d,symbols:p,components:b,dependencies:x,slices:S,notCompared:st(y,v)}}function st(e,t){let n=[];for(let[r,i]of e){let e=t.get(r);e!==void 0&&n.push({path:r,baseReason:i,headReason:e})}return n.sort((e,t)=>e.path<t.path?-1:+(e.path>t.path))}function ct(e,t){if(e.$schema!==t.$schema)throw new s(`Base IR schema "${e.$schema}" does not match head IR schema "${t.$schema}"; a diff across schema versions is not supported.`,{code:`schema-mismatch`,value:e.$schema})}const Q=e=>e.join(``),lt=[{field:`symbols`,identityFields:[`id`],keyOf:Q,noun:`id`,show:Q,consequence:`stage 1 pairs Symbols by id and every later stage tracks the base Symbols it has consumed by id, so a repeat leaves one entry out of the diff entirely or classifies its counterpart twice (ir-schema.md §14 #1)`},{field:`components`,identityFields:[`id`],keyOf:Q,noun:`id`,show:Q,consequence:`Component identity is the id, so a repeat hides one entry and can report a change the two revisions do not contain (ir-schema.md §14 #2)`},{field:`dependencies`,identityFields:f,keyOf:p,noun:`(from, to, via) triple`,show:e=>`(${e.join(`, `)})`,consequence:`direction and effect are deliberately outside Dependency identity (§6.2), so a repeat surfaces as an added + removed pair no reader can tell from a real flip (ir-schema.md §14 #13)`}];function ut(e,t){let r=n(e),i=r[0];if(i!==void 0){let e=dt(t,i.subject),n=r.length-1,a=n>0?` (and ${n} more)`:``;throw new s(`${e}: ${i.message}${a}.`,{code:`ir-shape-invalid`,value:e,violations:r.map(e=>({...e,subject:dt(t,e.subject)}))})}if(e.$schema.length===0)throw $(t,`${t}: "$schema" is empty, not a schema URL.`);for(let n of lt)ft(e[n.field],`${t}.${n.field}`,n)}function dt(t,n){return n===e?t:`${t}.${n}`}function ft(e,t,n){if(!Array.isArray(e))throw $(t,`${t} must be an array.`);let r=new Map;for(let[i,a]of e.entries()){let e=`${t}[${i}]`,o=pt(a,e,n),c=n.keyOf(o),l=r.get(c);if(l===void 0){r.set(c,i);continue}let u=n.show(o);throw new s(`${e} repeats the ${n.noun} "${u}" first seen at index ${l}; ${n.consequence}.`,{code:`ir-identity-collision`,value:u})}}function pt(e,t,n){if(typeof e!=`object`||!e||Array.isArray(e))throw $(t,`${t} must be an object; got ${mt(e)}.`);let r=[];for(let i of n.identityFields){let n=e[i];if(typeof n!=`string`)throw $(`${t}.${i}`,`${t}.${i} must be a string; got ${mt(n)}.`);r.push(n)}return r}function $(e,t){return new s(t,{code:`ir-shape-invalid`,value:e})}function mt(e){return e===void 0?`undefined`:e===null?`null`:Array.isArray(e)?`an array`:`a ${typeof e}`}function ht(e,t){if(e.status!==t.status)return e.status<t.status?-1:1;let n=gt(e),r=gt(t);return n<r?-1:+(n>r)}function gt(e){return e.status===`added`||e.status===`removed`||e.status===`unknown`?e.symbol.id:e.after.id}function _t(e,t={}){return a(e,t)}export{S as DEFAULT_LINE_FUZZ,s as DiffError,ee as MAX_LINE_FUZZ,te as MIN_LINE_FUZZ,Z as assertSliceRecordInvariant,ot as buildDiff,rt as classifyStatus,Ke as computeSlices,C as computeSymbolDelta,l as dependencySideView,c as diffComponents,u as diffDependencies,it as dropDirection,be as jaccard,xe as jaccardTokens,R as lastSegment,Y as matchStageDroppedWeak,H as matchStageGitRename,V as matchStageId,U as matchStageLogicFingerprint,G as matchStageNameSignature,Ce as memberSimilarity,Se as nameSimilarity,we as ownersAreCompatible,E as signatureSimilarity,Ye as sliceAnchor,Xe as sliceRecordViolation,O as tokenizeName,_t as writeCanonicalDiff};