@aburi/diff 0.1.0 → 0.3.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/LICENSE +202 -0
- package/README.md +4 -4
- package/dist/index.d.mts +294 -42
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1408 -269
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -4
- package/src/components.ts +166 -13
- package/src/delta.ts +129 -30
- package/src/diff.ts +295 -38
- package/src/errors.ts +16 -0
- package/src/index.ts +13 -1
- package/src/match.ts +634 -175
- package/src/similarity.ts +299 -19
- package/src/slice.ts +327 -0
- package/src/status.ts +2 -2
package/src/similarity.ts
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
* The result is de-duplicated so `InvoiceService.createInvoice` collapses to
|
|
15
15
|
* `["invoice", "service", "create"]` — Jaccard similarity relies on set semantics, so
|
|
16
16
|
* repeated tokens must not double-count.
|
|
17
|
+
*
|
|
18
|
+
* **The camel boundary is ASCII.** `isCamelBoundary` compares code points against `a`–`z`,
|
|
19
|
+
* `A`–`Z` and `0`–`9`, so a name written in a script with no ASCII case boundary and no
|
|
20
|
+
* separator comes back whole: `获取用户信息` and `ユーザー情報を取得する` are one token each,
|
|
21
|
+
* and so is `получитьПользователя` — its camel hump does not register. A separator still
|
|
22
|
+
* splits (`ユーザー.取得` gives two), and a mixed name splits on its ASCII half.
|
|
23
|
+
*
|
|
24
|
+
* That makes the token count a poor measure of how much such a name says, which matters
|
|
25
|
+
* wherever a count is read as a proxy for that — see §3.4.3's admissibility rule.
|
|
17
26
|
*/
|
|
18
27
|
export function tokenizeName(input: string): string[] {
|
|
19
28
|
const seen = new Set<string>()
|
|
@@ -70,50 +79,321 @@ function isCamelBoundary(prev: string, curr: string): boolean {
|
|
|
70
79
|
* purpose of §3.4.1). Empty on one side only returns 0.0.
|
|
71
80
|
*/
|
|
72
81
|
export function jaccard(a: readonly string[], b: readonly string[]): number {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
return jaccardSets(new Set(a), new Set(b))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function jaccardSets(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
|
|
86
|
+
if (a.size === 0 && b.size === 0) return 1
|
|
87
|
+
if (a.size === 0 || b.size === 0) return 0
|
|
88
|
+
const [small, large] = a.size <= b.size ? [a, b] : [b, a]
|
|
77
89
|
let intersection = 0
|
|
78
|
-
for (const
|
|
79
|
-
|
|
80
|
-
return intersection / union
|
|
90
|
+
for (const token of small) if (large.has(token)) intersection++
|
|
91
|
+
return intersection / (a.size + b.size - intersection)
|
|
81
92
|
}
|
|
82
93
|
|
|
83
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* Jaccard over the tokens of two strings. Part of the module's surface rather than an
|
|
96
|
+
* internal shortcut: the two formulas below go through a token table they share for one
|
|
97
|
+
* matching pass, so this has no caller inside this file.
|
|
98
|
+
*/
|
|
84
99
|
export function jaccardTokens(a: string, b: string): number {
|
|
85
100
|
return jaccard(tokenizeName(a), tokenizeName(b))
|
|
86
101
|
}
|
|
87
102
|
|
|
103
|
+
/** How a formula gets the token set of a name. The only thing the memo below changes. */
|
|
104
|
+
type TokenSetOf = (value: string) => ReadonlySet<string>
|
|
105
|
+
|
|
106
|
+
const tokenizeEveryTime: TokenSetOf = (value) => new Set(tokenizeName(value))
|
|
107
|
+
|
|
88
108
|
/**
|
|
89
109
|
* §3.4.1 — Jaccard over the tokens of the full qualified name. The last segment is not
|
|
90
110
|
* given extra weight; the entire path contributes uniformly because the intersection is
|
|
91
111
|
* what the score should reward.
|
|
92
112
|
*/
|
|
93
113
|
export function nameSimilarity(baseName: string, headName: string): number {
|
|
94
|
-
return
|
|
114
|
+
return nameFormula(tokenizeEveryTime, baseName, headName)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* §3.4.1 — Jaccard over the tokens of the **last segment**: the member name with its owner
|
|
119
|
+
* removed. What §3.4's composite reads, because §3.4.6 decides the owner separately and
|
|
120
|
+
* reading it on both axes charges for it twice.
|
|
121
|
+
*/
|
|
122
|
+
export function memberSimilarity(baseName: string, headName: string): number {
|
|
123
|
+
return memberFormula(tokenizeEveryTime, baseName, headName)
|
|
95
124
|
}
|
|
96
125
|
|
|
97
126
|
/**
|
|
98
|
-
* §3.4.6 —
|
|
99
|
-
*
|
|
100
|
-
*
|
|
127
|
+
* §3.4.6 (R-8) — whether two Symbols are close enough in *scope* to be the same Symbol: the
|
|
128
|
+
* same owner, or one whose owner was renamed. A gate rather than a score, because grading the
|
|
129
|
+
* owner cannot do what R-8 asks. `UserRepo.findById` and `AdminRepo.findById` agree on their
|
|
130
|
+
* member name and their signature, so a shared `Repo` token at weight 0.2 carries them to
|
|
131
|
+
* 0.8667 against a 0.85 threshold — while `UsersRepository.findById`, which *is* the rename,
|
|
132
|
+
* shares no owner token and scores 0.8000. The collision outscores the rename, and raising the
|
|
133
|
+
* weight only moves both: at 0.3 two three-token class names sharing two tokens land on
|
|
134
|
+
* exactly 0.85.
|
|
101
135
|
*
|
|
102
136
|
* Owner extraction:
|
|
103
137
|
* - `Class::method` → `Class`
|
|
104
138
|
* - `A.B.C.method` → `A.B.C`
|
|
105
139
|
* - `topLevel` → `` (empty)
|
|
106
140
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
141
|
+
* Two empty owners are compatible: top-level Symbols share the one outer scope. One empty and
|
|
142
|
+
* one not are never compatible — the two live at different depths.
|
|
143
|
+
*
|
|
144
|
+
* Otherwise the owners must correspond segment for segment, and within a segment every token
|
|
145
|
+
* on each side must find a distinct partner on the other under `sameWord`. Both sides must be
|
|
146
|
+
* covered, so `UserRepo` and `UserRepoV2` are two classes rather than one renamed — an added
|
|
147
|
+
* token is as much evidence of a sibling as of a rename, and R-8's business is refusing the
|
|
148
|
+
* collision.
|
|
149
|
+
*/
|
|
150
|
+
export function ownersAreCompatible(baseName: string, headName: string): boolean {
|
|
151
|
+
return ownerGate(tokenizeEveryTime, baseName, headName)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* §3.4.6 — the tokens `a` and `b` name the same thing.
|
|
156
|
+
*
|
|
157
|
+
* Equal, or the same word inflected: `user`/`users`, `entity`/`entities`. Nothing else, and
|
|
158
|
+
* that is the whole of the rule rather than a first approximation.
|
|
159
|
+
*
|
|
160
|
+
* A bare prefix test is the obvious generalisation and it cannot be made to work. It admits
|
|
161
|
+
* `repo`/`report`, `cache`/`cached`, `con`/`controller` — two distinct classes, which is
|
|
162
|
+
* exactly the collision R-8 exists to refuse. Every measure that might separate those from a
|
|
163
|
+
* real rename fails, because the two populations interleave rather than sitting on opposite
|
|
164
|
+
* sides of anything:
|
|
165
|
+
*
|
|
166
|
+
* ```
|
|
167
|
+
* dice levenshtein
|
|
168
|
+
* accept UserRepo/UsersRepository 0.571 7
|
|
169
|
+
* reject RepoManager/ReportManager 0.818 2
|
|
170
|
+
* accept Repo/Repository 0.500 6
|
|
171
|
+
* reject CacheStore/CachedStore 0.842 1
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* The renames to accept score *lower* than the collisions to refuse, on both. There is no
|
|
175
|
+
* threshold, and a length-growth rule fares no better: `con`→`controller` grows by 7 and
|
|
176
|
+
* `user`→`users` by 1, so anything admitting the second admits the first.
|
|
177
|
+
*
|
|
178
|
+
* Inflection is not on that spectrum. It is a closed, mechanical relation between two spellings
|
|
179
|
+
* of one word, so it can be recognised rather than estimated — and it covers the case a plain
|
|
180
|
+
* equality test misses most often, a class pluralised in place.
|
|
181
|
+
*
|
|
182
|
+
* What this gives up is the abbreviation family: `UserRepo` → `UsersRepository` no longer
|
|
183
|
+
* clears the gate on `repo`/`repository`. That is a real rename reported as `added` +
|
|
184
|
+
* `removed`, and §3.4.6 records it as the price of refusing `repo`/`report`, which no rule
|
|
185
|
+
* over the two strings alone can tell apart. The evidence that would settle it is not in the
|
|
186
|
+
* strings: the owner is itself a Symbol, and whether *it* paired is the question being
|
|
187
|
+
* guessed at here.
|
|
188
|
+
*/
|
|
189
|
+
function sameWord(a: string, b: string): boolean {
|
|
190
|
+
if (a === b) return true
|
|
191
|
+
const [short, long] = a.length <= b.length ? [a, b] : [b, a]
|
|
192
|
+
return pluralises(short, long)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The longest owner segment the matching will search.
|
|
197
|
+
*
|
|
198
|
+
* `augment` is Kuhn's, so the search is cubic in the token count and recursive with it. A
|
|
199
|
+
* class name is a handful of words; anything past this is not a name the gate has an opinion
|
|
200
|
+
* about, and `buildDiff` takes IR JSON from a caller rather than only from `aburi scan`, so
|
|
201
|
+
* the bound is what turns an adversarial 1600-token identifier from a hang into an answer.
|
|
202
|
+
* Refusing is the safe direction: the pair falls to `added` + `removed`.
|
|
110
203
|
*/
|
|
111
|
-
|
|
204
|
+
const MAX_OWNER_SEGMENT_TOKENS = 32
|
|
205
|
+
|
|
206
|
+
/** English noun inflection, which is what a pluralised class name goes through. */
|
|
207
|
+
function pluralises(singular: string, plural: string): boolean {
|
|
208
|
+
if (plural === `${singular}s` || plural === `${singular}es`) return true
|
|
209
|
+
return singular.endsWith("y") && plural === `${singular.slice(0, -1)}ies`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function nameFormula(setOf: TokenSetOf, baseName: string, headName: string): number {
|
|
213
|
+
return jaccardSets(setOf(baseName), setOf(headName))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function memberFormula(setOf: TokenSetOf, baseName: string, headName: string): number {
|
|
217
|
+
return jaccardSets(setOf(lastSegment(baseName)), setOf(lastSegment(headName)))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function ownerGate(setOf: TokenSetOf, baseName: string, headName: string): boolean {
|
|
112
221
|
const baseOwner = extractOwner(baseName)
|
|
113
222
|
const headOwner = extractOwner(headName)
|
|
114
|
-
if (baseOwner === "" && headOwner === "") return
|
|
115
|
-
if (baseOwner === "" || headOwner === "") return
|
|
116
|
-
|
|
223
|
+
if (baseOwner === "" && headOwner === "") return true
|
|
224
|
+
if (baseOwner === "" || headOwner === "") return false
|
|
225
|
+
// The common case, and the one a bucket of methods on one class hits every time.
|
|
226
|
+
if (baseOwner === headOwner) return true
|
|
227
|
+
return segmentsCorrespond(baseOwner, headOwner, setOf)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* §3.4.6 — the two owners name the same scope: segment for segment, token for token.
|
|
232
|
+
*
|
|
233
|
+
* Compared per segment rather than over the owner as a whole, because `tokenizeName` dedups
|
|
234
|
+
* and an owner is a *path*. `Users.UserRepo` collapses to `{users, user, repo}` while
|
|
235
|
+
* `Users.UsersRepository` collapses to `{users, repository}` — the namespace and the class
|
|
236
|
+
* share a word, so one side loses a token and the two stop being comparable before any
|
|
237
|
+
* spelling is looked at. Segment by segment they line up: `Users`/`Users`, then
|
|
238
|
+
* `UserRepo`/`UsersRepository`.
|
|
239
|
+
*
|
|
240
|
+
* A rename changes what a class is called, not how deeply it is nested, so a differing segment
|
|
241
|
+
* count is a differing scope.
|
|
242
|
+
*/
|
|
243
|
+
function segmentsCorrespond(baseOwner: string, headOwner: string, setOf: TokenSetOf): boolean {
|
|
244
|
+
const baseSegments = baseOwner.split(".")
|
|
245
|
+
const headSegments = headOwner.split(".")
|
|
246
|
+
if (baseSegments.length !== headSegments.length) return false
|
|
247
|
+
return baseSegments.every((segment, index) => {
|
|
248
|
+
const counterpart = headSegments[index]
|
|
249
|
+
if (counterpart === undefined) return false
|
|
250
|
+
if (segment === counterpart) return true
|
|
251
|
+
return coversBothWays(setOf(segment), setOf(counterpart))
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Whether the two token sets admit a perfect matching under `sameWord`.
|
|
257
|
+
*
|
|
258
|
+
* Sizes must agree, after which an injection from one side is a bijection — so only one
|
|
259
|
+
* direction is searched. The search is augmenting-path rather than greedy, because a greedy
|
|
260
|
+
* pass can strand a token a different choice would have matched: over `{user, users}` and
|
|
261
|
+
* `{user, userx}`, taking the identical pair first leaves `users` with nothing, though
|
|
262
|
+
* `user`→`userx` with `users`→`user` covers both. One segment runs to a handful of tokens, so
|
|
263
|
+
* the exact answer costs nothing worth saving.
|
|
264
|
+
*/
|
|
265
|
+
function coversBothWays(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
266
|
+
if (a.size !== b.size) return false
|
|
267
|
+
if (a.size > MAX_OWNER_SEGMENT_TOKENS) return false
|
|
268
|
+
const right = [...b]
|
|
269
|
+
const partnerOf = new Map<string, string>()
|
|
270
|
+
for (const token of a) {
|
|
271
|
+
if (!augment(token, right, partnerOf, new Set())) return false
|
|
272
|
+
}
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Kuhn's step: claim a free partner for `token`, or displace one that can move on. */
|
|
277
|
+
function augment(
|
|
278
|
+
token: string,
|
|
279
|
+
right: readonly string[],
|
|
280
|
+
partnerOf: Map<string, string>,
|
|
281
|
+
visited: Set<string>,
|
|
282
|
+
): boolean {
|
|
283
|
+
for (const candidate of right) {
|
|
284
|
+
if (visited.has(candidate) || !sameWord(token, candidate)) continue
|
|
285
|
+
visited.add(candidate)
|
|
286
|
+
const holder = partnerOf.get(candidate)
|
|
287
|
+
if (holder === undefined || augment(holder, right, partnerOf, visited)) {
|
|
288
|
+
partnerOf.set(candidate, token)
|
|
289
|
+
return true
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return false
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* §3.4.6 — a necessary condition on two non-empty owners, cheaper than the full gate: their
|
|
297
|
+
* first segments must correspond, since segments are compared positionally.
|
|
298
|
+
*
|
|
299
|
+
* A filter, not a decision. It relaxes `coversBothWays`' perfect matching to "every token
|
|
300
|
+
* finds some partner", so it can only ever admit more than the gate does; answering `true`
|
|
301
|
+
* says the gate has to be asked. Loosening it further costs correctness nothing and speed a
|
|
302
|
+
* little — refusing something the gate would accept is the direction that would break recall.
|
|
303
|
+
*/
|
|
304
|
+
function firstSegmentsCouldAgree(baseOwner: string, headOwner: string, setOf: TokenSetOf): boolean {
|
|
305
|
+
const baseFirst = baseOwner.slice(0, dotOrEnd(baseOwner))
|
|
306
|
+
const headFirst = headOwner.slice(0, dotOrEnd(headOwner))
|
|
307
|
+
if (baseFirst === headFirst) return true
|
|
308
|
+
const baseTokens = setOf(baseFirst)
|
|
309
|
+
const headTokens = setOf(headFirst)
|
|
310
|
+
if (baseTokens.size !== headTokens.size) return false
|
|
311
|
+
// Still a necessary condition at the ceiling, because `coversBothWays` refuses a segment
|
|
312
|
+
// this wide outright — and refusing here keeps that an O(1) answer rather than an all-pairs
|
|
313
|
+
// scan run ahead of it, which is the adversarial input the ceiling exists for.
|
|
314
|
+
if (baseTokens.size > MAX_OWNER_SEGMENT_TOKENS) return false
|
|
315
|
+
for (const token of baseTokens) {
|
|
316
|
+
let partnered = false
|
|
317
|
+
for (const candidate of headTokens) {
|
|
318
|
+
if (sameWord(token, candidate)) {
|
|
319
|
+
partnered = true
|
|
320
|
+
break
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!partnered) return false
|
|
324
|
+
}
|
|
325
|
+
return true
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function dotOrEnd(owner: string): number {
|
|
329
|
+
const dot = owner.indexOf(".")
|
|
330
|
+
return dot < 0 ? owner.length : dot
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The formulas §3.4 reads, over a token table shared for one matching pass. */
|
|
334
|
+
export interface NameScorer {
|
|
335
|
+
name(baseName: string, headName: string): number
|
|
336
|
+
member(baseName: string, headName: string): number
|
|
337
|
+
ownersCompatible(baseName: string, headName: string): boolean
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A scorer that tokenises each distinct name once.
|
|
342
|
+
*
|
|
343
|
+
* Stage 4 scores every (base, head) pair in a bucket, so a bucket of K on each side asks for
|
|
344
|
+
* K² similarities over 2K distinct names — tokenising on every call splits the same strings
|
|
345
|
+
* into the same tokens hundreds of thousands of times. The table lives for one call rather
|
|
346
|
+
* than for the process, because the names it holds are only the ones that pass is comparing.
|
|
347
|
+
*/
|
|
348
|
+
export function createNameScorer(): NameScorer {
|
|
349
|
+
const sets = new Map<string, ReadonlySet<string>>()
|
|
350
|
+
const setOf: TokenSetOf = (value) => {
|
|
351
|
+
const cached = sets.get(value)
|
|
352
|
+
if (cached !== undefined) return cached
|
|
353
|
+
const built: ReadonlySet<string> = new Set(tokenizeName(value))
|
|
354
|
+
sets.set(value, built)
|
|
355
|
+
return built
|
|
356
|
+
}
|
|
357
|
+
const owners = new Map<string, string>()
|
|
358
|
+
const ownerOf = (qname: string): string => {
|
|
359
|
+
const cached = owners.get(qname)
|
|
360
|
+
if (cached !== undefined) return cached
|
|
361
|
+
const built = extractOwner(qname)
|
|
362
|
+
owners.set(qname, built)
|
|
363
|
+
return built
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* §3.4.6's gate, with the segment split and the matching skipped where they can be.
|
|
367
|
+
*
|
|
368
|
+
* The gate is the costliest thing stage 4 asks per candidate — it splits both owners into
|
|
369
|
+
* segments, tokenises each and runs an augmenting-path matching. Two shortcuts, in order of
|
|
370
|
+
* how much they save:
|
|
371
|
+
*
|
|
372
|
+
* - **Identical owners.** A bucket of methods on one class asks about the same owner over
|
|
373
|
+
* and over, and `ownerGate` already answers those on a string compare; doing it here skips
|
|
374
|
+
* the two `extractOwner` calls as well, which are substrings on every candidate.
|
|
375
|
+
* - **A different first segment.** Segments correspond positionally, so owners whose first
|
|
376
|
+
* segments cannot be the same word are incompatible whatever the rest says. The check is
|
|
377
|
+
* one `sameWord` over two token sets the table already holds, against a full matching over
|
|
378
|
+
* every segment.
|
|
379
|
+
*
|
|
380
|
+
* Not memoised on the pair. A bulk rename produces as many distinct owner pairs as
|
|
381
|
+
* candidates, so a table keyed on them grows with the cross-product — enough to exceed
|
|
382
|
+
* V8's Map limit on the very input this is meant to make fast.
|
|
383
|
+
*/
|
|
384
|
+
const compatible = (baseName: string, headName: string): boolean => {
|
|
385
|
+
const baseOwner = ownerOf(baseName)
|
|
386
|
+
const headOwner = ownerOf(headName)
|
|
387
|
+
if (baseOwner === headOwner) return true
|
|
388
|
+
if (baseOwner === "" || headOwner === "") return false
|
|
389
|
+
if (!firstSegmentsCouldAgree(baseOwner, headOwner, setOf)) return false
|
|
390
|
+
return segmentsCorrespond(baseOwner, headOwner, setOf)
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
name: (baseName, headName) => nameFormula(setOf, baseName, headName),
|
|
394
|
+
member: (baseName, headName) => memberFormula(setOf, baseName, headName),
|
|
395
|
+
ownersCompatible: compatible,
|
|
396
|
+
}
|
|
117
397
|
}
|
|
118
398
|
|
|
119
399
|
function extractOwner(qname: string): string {
|
package/src/slice.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { type CallEdge, computeWeaklyConnectedComponents, RESERVED_LANGUAGE_IDS } from "@aburi/core"
|
|
2
|
+
import type { SliceId, SliceRecord, SymbolChange, SymbolId } from "@aburi/types"
|
|
3
|
+
import { DiffError } from "./errors"
|
|
4
|
+
|
|
5
|
+
/** The three inputs of the Slice View pass — docs/design/slice-view.md §3. */
|
|
6
|
+
export interface SliceInput {
|
|
7
|
+
/** SymbolChange records produced by `buildDiff` (pre-sort is not required). */
|
|
8
|
+
changes: readonly SymbolChange[]
|
|
9
|
+
/**
|
|
10
|
+
* Resolved call edges from the base IR. Typically produced by
|
|
11
|
+
* `reconstructCallEdgesFromIR(baseIR)` inside `buildDiff` — Slice View
|
|
12
|
+
* consumes only resolved edges (§5.4), never `Symbol.calls[]` directly.
|
|
13
|
+
*/
|
|
14
|
+
baseCallEdges: readonly CallEdge[]
|
|
15
|
+
/** Resolved call edges from the head IR (same source rule as base). */
|
|
16
|
+
headCallEdges: readonly CallEdge[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Slice View clustering pass — docs/design/slice-view.md §2, §4–§8.
|
|
21
|
+
*
|
|
22
|
+
* Groups the changed-Symbol set of a diff into weakly-connected components
|
|
23
|
+
* over the union of base and head call edges. Emits a `SliceRecord[]` whose
|
|
24
|
+
* cluster ids are `"slice:" + <smallest-member Symbol id>`, both `slices[]`
|
|
25
|
+
* and each `members[]` sorted ascending.
|
|
26
|
+
*
|
|
27
|
+
* Pure function: `(changes, baseCallEdges, headCallEdges)` → `SliceRecord[]`.
|
|
28
|
+
* Determinism, idempotence, input-order insensitivity, and locality are all
|
|
29
|
+
* guaranteed — see `computeWeaklyConnectedComponents` in `@aburi/core`.
|
|
30
|
+
*
|
|
31
|
+
* Returns `[]` when no SymbolChange is Node-eligible per §4.1; callers should
|
|
32
|
+
* still serialise the empty array — the Markdown projection omits the section
|
|
33
|
+
* (§9.4 / §12.5) but the JSON always emits the key so consumers never
|
|
34
|
+
* distinguish "field absent" from "no slices" (§11.2).
|
|
35
|
+
*
|
|
36
|
+
* Every returned record has passed `assertSliceRecordInvariant` (§7.4).
|
|
37
|
+
*/
|
|
38
|
+
export function computeSlices(input: SliceInput): SliceRecord[] {
|
|
39
|
+
const nodeIds = collectNodeIds(input.changes)
|
|
40
|
+
if (nodeIds.length === 0) return []
|
|
41
|
+
|
|
42
|
+
const nodeIdSet = new Set<SymbolId>(nodeIds)
|
|
43
|
+
const edges = collectEdges(input.baseCallEdges, input.headCallEdges, nodeIdSet)
|
|
44
|
+
|
|
45
|
+
const components = computeWeaklyConnectedComponents<SymbolId>(nodeIds, edges, (id) => id)
|
|
46
|
+
|
|
47
|
+
return components.map(makeSliceRecord)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SLICE_ID_PREFIX = "slice:"
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* §7.1 — the single place production code derives a Slice id. Everything else
|
|
54
|
+
* in `src/` either receives an id or renders one. (Tests spell the prefix out
|
|
55
|
+
* literally on purpose: an expectation written in terms of the function under
|
|
56
|
+
* test would agree with it no matter what it produced.)
|
|
57
|
+
*
|
|
58
|
+
* The cast is the one place a `SliceId` comes into existence. `SliceId` and
|
|
59
|
+
* `SymbolId` are separate brands precisely so this concatenation cannot be
|
|
60
|
+
* open-coded anywhere else: a bare `"slice:" + x` evaluates to `string`, which
|
|
61
|
+
* `SliceRecord.id` no longer accepts.
|
|
62
|
+
*/
|
|
63
|
+
function sliceIdFor(anchor: SymbolId): SliceId {
|
|
64
|
+
return `${SLICE_ID_PREFIX}${anchor}` as SliceId
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build one SliceRecord from an ascending-sorted component and check its own
|
|
69
|
+
* post-condition before letting it out (§7.4 layer 1).
|
|
70
|
+
*
|
|
71
|
+
* The check is not defending against untrusted input — this function builds
|
|
72
|
+
* the id itself, so the derivation clause is true by construction here and
|
|
73
|
+
* only the two clauses about `members` can actually fire. Those are the point:
|
|
74
|
+
* `members[0]` is the anchor only because `computeWeaklyConnectedComponents`
|
|
75
|
+
* returns each component sorted ascending, a guarantee that lives one layer
|
|
76
|
+
* down and is invisible from this file. If it ever stops holding, the diff
|
|
77
|
+
* would otherwise keep emitting well-formed-looking SliceRecords naming the
|
|
78
|
+
* wrong anchor. Here it fails instead.
|
|
79
|
+
*/
|
|
80
|
+
function makeSliceRecord(members: readonly SymbolId[]): SliceRecord {
|
|
81
|
+
const anchor = members[0]
|
|
82
|
+
if (anchor === undefined) {
|
|
83
|
+
throw new DiffError(
|
|
84
|
+
"computeSlices: the clustering utility returned an empty component; every weakly-connected " +
|
|
85
|
+
"component contains at least the node that seeded it (slice-view.md §6).",
|
|
86
|
+
{ code: "slice-invariant-violated" },
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
const record: SliceRecord = { id: sliceIdFor(anchor), members: [...members] }
|
|
90
|
+
assertSliceRecordInvariant(record)
|
|
91
|
+
return record
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* §7.1 — the anchor of a Slice: its lexicographically smallest member.
|
|
96
|
+
*
|
|
97
|
+
* Answers from `members[0]`. `id` is derived from the anchor, so
|
|
98
|
+
* reconstructing the anchor by stripping the `"slice:"` prefix is circular at
|
|
99
|
+
* best and, for a record that broke the derivation, silently wrong: it would
|
|
100
|
+
* name a Symbol that is not in the Slice. `id` is read here only to name the
|
|
101
|
+
* offending record in the error message. Consumers that need the anchor call
|
|
102
|
+
* this; consumers that need a label keep using `id` directly.
|
|
103
|
+
*/
|
|
104
|
+
export function sliceAnchor(record: SliceRecord): SymbolId {
|
|
105
|
+
const anchor = record.members[0]
|
|
106
|
+
if (anchor === undefined) {
|
|
107
|
+
const violation = emptyMembersViolation(record.id)
|
|
108
|
+
throw new DiffError(violation.message, {
|
|
109
|
+
code: "slice-invariant-violated",
|
|
110
|
+
value: violation.subject,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
return anchor
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Which clause of the §7.1 / §8.2 invariant a `SliceRecord` broke. */
|
|
117
|
+
export type SliceViolationKind =
|
|
118
|
+
/** Not a `SliceRecord` at all: not an object, or `id` / `members` of the wrong type. */
|
|
119
|
+
| "malformed-shape"
|
|
120
|
+
/** `members[]` has no entries, so the Slice has no anchor (§11.1). */
|
|
121
|
+
| "members-empty"
|
|
122
|
+
/** `members[]` is not in strictly ascending order, so `members[0]` need not be the smallest (§8.2, §11.1). */
|
|
123
|
+
| "members-unordered"
|
|
124
|
+
/** `id` is not `"slice:" + members[0]` (§7.1). */
|
|
125
|
+
| "id-not-derived"
|
|
126
|
+
/** The anchor is itself in a reserved id namespace, so `id` would read as a doubled prefix (§7.5). */
|
|
127
|
+
| "anchor-in-reserved-namespace"
|
|
128
|
+
|
|
129
|
+
export interface SliceRecordViolation {
|
|
130
|
+
kind: SliceViolationKind
|
|
131
|
+
/** The offending record's `id` when it has a usable one, else a short stand-in. */
|
|
132
|
+
subject: string
|
|
133
|
+
/** Human-facing explanation, suitable for an error message or a validator error. */
|
|
134
|
+
message: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Report which clause of the §7.1 / §8.2 invariant a value breaks, or `null`
|
|
139
|
+
* when it is a well-formed `SliceRecord`. Non-throwing counterpart of
|
|
140
|
+
* `assertSliceRecordInvariant`: the pass wants an exception, a schema
|
|
141
|
+
* validator wants a verdict it can turn into its own error.
|
|
142
|
+
*
|
|
143
|
+
* Takes `unknown` rather than `SliceRecord` deliberately. §7.4 layer 2 points
|
|
144
|
+
* this at documents written by third-party or older producers — data that has
|
|
145
|
+
* not been type-checked by definition — so anything that assumed a well-typed
|
|
146
|
+
* argument would crash on exactly the input it exists to reject.
|
|
147
|
+
*/
|
|
148
|
+
export function sliceRecordViolation(value: unknown): SliceRecordViolation | null {
|
|
149
|
+
if (typeof value !== "object" || value === null) {
|
|
150
|
+
return {
|
|
151
|
+
kind: "malformed-shape",
|
|
152
|
+
subject: "<not an object>",
|
|
153
|
+
message: `Expected a SliceRecord object; got ${value === null ? "null" : typeof value}.`,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const { id, members } = value as { id?: unknown; members?: unknown }
|
|
157
|
+
const subject = typeof id === "string" ? id : "<missing id>"
|
|
158
|
+
if (typeof id !== "string") {
|
|
159
|
+
return {
|
|
160
|
+
kind: "malformed-shape",
|
|
161
|
+
subject,
|
|
162
|
+
message: `SliceRecord ${subject}: id must be a string; got ${typeof id}.`,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!Array.isArray(members) || members.some((member) => typeof member !== "string")) {
|
|
166
|
+
return {
|
|
167
|
+
kind: "malformed-shape",
|
|
168
|
+
subject,
|
|
169
|
+
message: `SliceRecord ${subject}: members must be an array of strings.`,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const anchor = members[0]
|
|
174
|
+
if (anchor === undefined) return emptyMembersViolation(subject)
|
|
175
|
+
// A Symbol id in the `slice:` namespace derives to `slice:slice:…`, which is
|
|
176
|
+
// self-consistent — it passes the derivation clause below — but names an id no reader can
|
|
177
|
+
// tell from a Slice id. `makeSymbolId` refuses to build such a Symbol id and
|
|
178
|
+
// `checkIRIntegrity` #16 rejects one read from disk, but `buildDiff` is public API and
|
|
179
|
+
// runs no integrity check, so the doubled prefix is caught here too.
|
|
180
|
+
const reservedAnchor = reservedNamespaceOf(anchor)
|
|
181
|
+
if (reservedAnchor !== null) {
|
|
182
|
+
return {
|
|
183
|
+
kind: "anchor-in-reserved-namespace",
|
|
184
|
+
subject,
|
|
185
|
+
message:
|
|
186
|
+
`SliceRecord anchor "${anchor}" uses the reserved language token "${reservedAnchor}", ` +
|
|
187
|
+
`so its Slice id would repeat the prefix (ir-schema.md §3.5, slice-view.md §7.5).`,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
for (let i = 1; i < members.length; i++) {
|
|
191
|
+
const previous = members[i - 1] as string
|
|
192
|
+
const current = members[i] as string
|
|
193
|
+
if (previous < current) continue
|
|
194
|
+
return {
|
|
195
|
+
kind: "members-unordered",
|
|
196
|
+
subject,
|
|
197
|
+
message:
|
|
198
|
+
`SliceRecord ${subject}: members[] is not in strictly ascending order at index ${i} ` +
|
|
199
|
+
`("${current}" follows "${previous}"), so members[0] is not necessarily the anchor ` +
|
|
200
|
+
`(slice-view.md §8.2 for the order, §11.1 for uniqueness).`,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// The one place outside `sliceIdFor` that asserts an id brand, and the reason this
|
|
204
|
+
// function takes `unknown`: the anchor has been checked to be a string and nothing more.
|
|
205
|
+
// Whether it is a well-formed Symbol id is not this check's question — a record whose
|
|
206
|
+
// members are gibberish still has to be told apart from one whose id disagrees with them.
|
|
207
|
+
const expected = sliceIdFor(anchor as SymbolId)
|
|
208
|
+
if (id !== expected) {
|
|
209
|
+
return {
|
|
210
|
+
kind: "id-not-derived",
|
|
211
|
+
subject,
|
|
212
|
+
message:
|
|
213
|
+
`SliceRecord id "${id}" is not derived from the anchor "${anchor}"; ` +
|
|
214
|
+
`expected "${expected}" (slice-view.md §7.1).`,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The reserved token an id opens with, or `null`. Shares the list `@aburi/core` enforces. */
|
|
221
|
+
function reservedNamespaceOf(id: string): string | null {
|
|
222
|
+
const colon = id.indexOf(":")
|
|
223
|
+
if (colon < 0) return null
|
|
224
|
+
const token = id.slice(0, colon)
|
|
225
|
+
return RESERVED_LANGUAGE_IDS.has(token) ? token : null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Single source of the empty-`members[]` wording, shared with `sliceAnchor`. */
|
|
229
|
+
function emptyMembersViolation(subject: string): SliceRecordViolation {
|
|
230
|
+
return {
|
|
231
|
+
kind: "members-empty",
|
|
232
|
+
subject,
|
|
233
|
+
message:
|
|
234
|
+
`SliceRecord ${subject}: members[] is empty, so the Slice has no anchor ` +
|
|
235
|
+
`(slice-view.md §11.1 requires at least one member).`,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Throwing form of `sliceRecordViolation`. Raised as a coded `DiffError` so
|
|
241
|
+
* callers branch on `code` rather than parsing the message; which clause broke
|
|
242
|
+
* is `SliceRecordViolation.kind`, for callers that need to tell them apart.
|
|
243
|
+
*/
|
|
244
|
+
export function assertSliceRecordInvariant(record: SliceRecord): void {
|
|
245
|
+
const violation = sliceRecordViolation(record)
|
|
246
|
+
if (violation === null) return
|
|
247
|
+
throw new DiffError(violation.message, {
|
|
248
|
+
code: "slice-invariant-violated",
|
|
249
|
+
value: violation.subject,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* §4.1 Node selection: keep every SymbolChange whose status is
|
|
255
|
+
* added / removed / changed / moved+changed / dropped-toggled. The identity
|
|
256
|
+
* is the head-side Symbol id where present (added / changed / moved+changed /
|
|
257
|
+
* dropped-toggled), the base-side id for removed. Pure moved and unchanged
|
|
258
|
+
* (already stripped upstream) are excluded — §4.1 / §4.3.
|
|
259
|
+
*
|
|
260
|
+
* The returned array is deliberately not deduplicated: a well-formed
|
|
261
|
+
* SymbolChange list from `buildDiff` never mentions the same Symbol id
|
|
262
|
+
* twice, and Union-Find would coalesce any accidental duplicate anyway.
|
|
263
|
+
*/
|
|
264
|
+
function collectNodeIds(changes: readonly SymbolChange[]): SymbolId[] {
|
|
265
|
+
const ids: SymbolId[] = []
|
|
266
|
+
for (const change of changes) {
|
|
267
|
+
const id = nodeIdOf(change)
|
|
268
|
+
if (id === null) continue
|
|
269
|
+
ids.push(id)
|
|
270
|
+
}
|
|
271
|
+
return ids
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function nodeIdOf(change: SymbolChange): SymbolId | null {
|
|
275
|
+
switch (change.status) {
|
|
276
|
+
case "added":
|
|
277
|
+
return change.symbol.id
|
|
278
|
+
case "removed":
|
|
279
|
+
// §4.1 — removed uses base-side id because there is no head symbol.
|
|
280
|
+
return change.symbol.id
|
|
281
|
+
case "unknown":
|
|
282
|
+
// The one side that exists carries the id, as for added / removed. An unknown entry
|
|
283
|
+
// is a thing a reviewer has to look at, so it belongs in the cluster its neighbours
|
|
284
|
+
// are in — a Symbol whose file went missing is most useful read beside the callers
|
|
285
|
+
// that still reference it.
|
|
286
|
+
return change.symbol.id
|
|
287
|
+
case "changed":
|
|
288
|
+
case "moved+changed":
|
|
289
|
+
case "dropped-toggled":
|
|
290
|
+
return change.after.id
|
|
291
|
+
case "moved":
|
|
292
|
+
// §4.3 — pure moved is not a Node and is not used for bridging either.
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
// Exhaustive: if SymbolChange grows a new status the compile error above
|
|
296
|
+
// fires before this fallback is reachable.
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* §5.1 Edge selection: union of base and head, restricted to edges whose
|
|
301
|
+
* BOTH endpoints are Nodes, canonicalised to `(u, v)` with `u < v` (self-
|
|
302
|
+
* loops implicitly dropped). Multi-edges collapse naturally inside the WCC
|
|
303
|
+
* primitive — no explicit dedup needed.
|
|
304
|
+
*
|
|
305
|
+
* The union is the load-bearing rule of §5.3: a controller that called an
|
|
306
|
+
* old service in base and a new service in head needs both edges to land
|
|
307
|
+
* all three Symbols in a single Slice.
|
|
308
|
+
*/
|
|
309
|
+
function collectEdges(
|
|
310
|
+
baseEdges: readonly CallEdge[],
|
|
311
|
+
headEdges: readonly CallEdge[],
|
|
312
|
+
nodeIds: ReadonlySet<SymbolId>,
|
|
313
|
+
): [SymbolId, SymbolId][] {
|
|
314
|
+
const pairs: [SymbolId, SymbolId][] = []
|
|
315
|
+
for (const edge of baseEdges) appendEdgeIfBothNodes(edge, nodeIds, pairs)
|
|
316
|
+
for (const edge of headEdges) appendEdgeIfBothNodes(edge, nodeIds, pairs)
|
|
317
|
+
return pairs
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function appendEdgeIfBothNodes(
|
|
321
|
+
edge: CallEdge,
|
|
322
|
+
nodeIds: ReadonlySet<SymbolId>,
|
|
323
|
+
out: [SymbolId, SymbolId][],
|
|
324
|
+
): void {
|
|
325
|
+
if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) return
|
|
326
|
+
out.push([edge.from, edge.to])
|
|
327
|
+
}
|