@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.d.mts +39 -17
- package/dist/index.mjs +1 -2180
- package/package.json +4 -5
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/src/components.ts +0 -258
- package/src/delta.ts +0 -400
- package/src/diff.ts +0 -494
- package/src/errors.ts +0 -51
- package/src/index.ts +0 -57
- package/src/match.ts +0 -797
- package/src/signature.ts +0 -53
- package/src/similarity.ts +0 -414
- package/src/slice.ts +0 -327
- package/src/status.ts +0 -36
package/src/components.ts
DELETED
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Component,
|
|
3
|
-
ComponentDiff,
|
|
4
|
-
ComponentId,
|
|
5
|
-
Dependency,
|
|
6
|
-
DependencyDiff,
|
|
7
|
-
DependencyEndpoint,
|
|
8
|
-
DependencyUnknown,
|
|
9
|
-
DiffSkippedFile,
|
|
10
|
-
IR,
|
|
11
|
-
RelativePath,
|
|
12
|
-
SkipReason,
|
|
13
|
-
} from "@aburi/types"
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* `docs/design/diff-algorithm.md` §6.1 — Component diff. Assumes `components[].id` is unique on each side (ir-schema.md §14
|
|
17
|
-
* #2) and does not check it: `buildDiff` establishes that before calling, and a caller
|
|
18
|
-
* reaching this export directly owns the obligation, because the lookup map here is
|
|
19
|
-
* last-write-wins and a repeat is lost rather than reported.
|
|
20
|
-
*
|
|
21
|
-
* Component identity is `id`; when a Component is present in both
|
|
22
|
-
* base and head with the same id, the three delta booleans describe which axes actually
|
|
23
|
-
* moved (roots reshuffle, public API globs, or framework hint list). `modified` deltas
|
|
24
|
-
* are intentionally absent from this diff (diff-algorithm.md §5.2.3): fields are reported as before/after
|
|
25
|
-
* pairs via the `changed[]` entries so consumers can render them without stringifying
|
|
26
|
-
* arrays.
|
|
27
|
-
*/
|
|
28
|
-
export function diffComponents(
|
|
29
|
-
base: readonly Component[],
|
|
30
|
-
head: readonly Component[],
|
|
31
|
-
): ComponentDiff {
|
|
32
|
-
const baseById = new Map<ComponentId, Component>()
|
|
33
|
-
for (const c of base) baseById.set(c.id, c)
|
|
34
|
-
const headById = new Map<ComponentId, Component>()
|
|
35
|
-
for (const c of head) headById.set(c.id, c)
|
|
36
|
-
|
|
37
|
-
const added: Component[] = []
|
|
38
|
-
const removed: Component[] = []
|
|
39
|
-
const changed: ComponentDiff["changed"] = []
|
|
40
|
-
|
|
41
|
-
for (const [id, headComp] of headById) {
|
|
42
|
-
const baseComp = baseById.get(id)
|
|
43
|
-
if (baseComp === undefined) {
|
|
44
|
-
added.push(headComp)
|
|
45
|
-
continue
|
|
46
|
-
}
|
|
47
|
-
const rootsChanged = !stringArraysEqual(baseComp.roots, headComp.roots)
|
|
48
|
-
const publicApiChanged = !stringArraysEqual(baseComp.publicApi ?? [], headComp.publicApi ?? [])
|
|
49
|
-
const frameworksChanged = !stringArraysEqual(
|
|
50
|
-
baseComp.frameworks ?? [],
|
|
51
|
-
headComp.frameworks ?? [],
|
|
52
|
-
)
|
|
53
|
-
if (rootsChanged || publicApiChanged || frameworksChanged) {
|
|
54
|
-
changed.push({
|
|
55
|
-
before: baseComp,
|
|
56
|
-
after: headComp,
|
|
57
|
-
delta: { rootsChanged, publicApiChanged, frameworksChanged },
|
|
58
|
-
})
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
for (const [id, baseComp] of baseById) {
|
|
62
|
-
if (!headById.has(id)) removed.push(baseComp)
|
|
63
|
-
}
|
|
64
|
-
sortById(added)
|
|
65
|
-
sortById(removed)
|
|
66
|
-
changed.sort((a, b) => (a.after.id < b.after.id ? -1 : a.after.id > b.after.id ? 1 : 0))
|
|
67
|
-
return { added, removed, changed }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* What one document knows about itself, for deciding whether the *other* document's silence
|
|
72
|
-
* about an edge is evidence.
|
|
73
|
-
*
|
|
74
|
-
* `symbolFiles` is keyed on the endpoint id exactly as `dependencies[]` spells it, and its
|
|
75
|
-
* values come from `symbols[].source.file` — the same space `stats.skippedFiles[].path` is in,
|
|
76
|
-
* and the same space `buildDiff` classifies Symbols by. Reading the file out of the id's path
|
|
77
|
-
* segment instead would introduce a second answer to "which file is this endpoint in", and
|
|
78
|
-
* nothing in the schema forces the two to agree; a Symbol reported `unknown` while its edges
|
|
79
|
-
* stayed `removed` is precisely the inconsistency this exists to remove.
|
|
80
|
-
*
|
|
81
|
-
* A Component endpoint is absent from the map, which is how it stays out of the reclassification
|
|
82
|
-
* without a special case: a Component is an aggregate over roots and has no file to lose.
|
|
83
|
-
*/
|
|
84
|
-
export interface DependencySideView {
|
|
85
|
-
/**
|
|
86
|
-
* `source.file` of every Symbol this document holds, by the id an endpoint would name it
|
|
87
|
-
* with. Keyed by `DependencyEndpoint` rather than `SymbolId` because that is the question
|
|
88
|
-
* being asked: the lookup happens with an endpoint whose kind is not yet known, and "absent"
|
|
89
|
-
* is the answer for a Component id. Demanding the kind up front would move that decision to
|
|
90
|
-
* the caller, where it would need a second Symbol-id silhouette test to make.
|
|
91
|
-
*/
|
|
92
|
-
symbolFiles: ReadonlyMap<DependencyEndpoint, RelativePath>
|
|
93
|
-
/**
|
|
94
|
-
* Files this document never analysed, by path, with the reason it gave. `RelativePath` on
|
|
95
|
-
* both sides of the file space is what states the bridge these two maps exist to make — one
|
|
96
|
-
* map's values are the other's keys — though the alias is unbranded, so it is intent rather
|
|
97
|
-
* than enforcement.
|
|
98
|
-
*/
|
|
99
|
-
lostFiles: ReadonlyMap<RelativePath, SkipReason>
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Build a side view from a document. The only construction site there is.
|
|
104
|
-
*
|
|
105
|
-
* `buildDiff` reads `lostFiles` for its own Symbol classification too, from this same object,
|
|
106
|
-
* so "a Symbol reported unknown and the edges it took with it cannot disagree about which file
|
|
107
|
-
* went missing" is a property of the wiring rather than a claim in a comment.
|
|
108
|
-
*
|
|
109
|
-
* Exported because `DependencySideView` is public and `diffDependencies` requires one: without
|
|
110
|
-
* a factory a caller would have to reproduce the `symbols[].source.file` keying and the
|
|
111
|
-
* `stats.skippedFiles` read, and the likeliest outcome of that is a caller who gets it wrong.
|
|
112
|
-
*/
|
|
113
|
-
export function dependencySideView(ir: IR): DependencySideView {
|
|
114
|
-
const symbolFiles = new Map<DependencyEndpoint, RelativePath>()
|
|
115
|
-
for (const symbol of ir.symbols) symbolFiles.set(symbol.id, symbol.source.file)
|
|
116
|
-
const lostFiles = new Map<RelativePath, SkipReason>()
|
|
117
|
-
for (const file of ir.stats.skippedFiles ?? []) lostFiles.set(file.path, file.reason)
|
|
118
|
-
return { symbolFiles, lostFiles }
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* `docs/design/diff-algorithm.md` §6.2 — Dependency diff. Identity is the composite
|
|
123
|
-
* `(from, to, via)` triple; direction and effect changes are surfaced as an added + removed
|
|
124
|
-
* pair so `modified` is not part of the schema (§6.2 tail). Uniqueness of that triple is the caller's obligation on the same
|
|
125
|
-
* terms as `diffComponents` above — a repeat here is indistinguishable in the output from
|
|
126
|
-
* the flip it encodes.
|
|
127
|
-
*
|
|
128
|
-
* `sides` is what separates a deletion from a loss (`docs/design/diff-algorithm.md` §6.2.1).
|
|
129
|
-
* It is required rather than optional: omitting it would classify every edge into a lost file
|
|
130
|
-
* as a deletion again — silently, and while still writing `unknown: []`, which this schema
|
|
131
|
-
* defines as "nothing was unknown" rather than "nobody looked". A caller with no skip list to
|
|
132
|
-
* offer says so by passing a side view whose `lostFiles` is empty, which is the honest spelling
|
|
133
|
-
* of an IR written before `stats.skippedFiles` existed.
|
|
134
|
-
*
|
|
135
|
-
* The return type declares `unknown` present, where the schema leaves it optional for documents
|
|
136
|
-
* that predate the field. A reader may find it missing; this function always writes it.
|
|
137
|
-
*/
|
|
138
|
-
export function diffDependencies(
|
|
139
|
-
base: readonly Dependency[],
|
|
140
|
-
head: readonly Dependency[],
|
|
141
|
-
sides: { base: DependencySideView; head: DependencySideView },
|
|
142
|
-
): DependencyDiff & { unknown: DependencyUnknown[] } {
|
|
143
|
-
const baseKeys = new Map<string, Dependency>()
|
|
144
|
-
for (const d of base) baseKeys.set(dependencyKey(d), d)
|
|
145
|
-
const headKeys = new Map<string, Dependency>()
|
|
146
|
-
for (const d of head) headKeys.set(dependencyKey(d), d)
|
|
147
|
-
const added: Dependency[] = []
|
|
148
|
-
const removed: Dependency[] = []
|
|
149
|
-
const unknown: DependencyUnknown[] = []
|
|
150
|
-
for (const [key, dep] of headKeys) {
|
|
151
|
-
const b = baseKeys.get(key)
|
|
152
|
-
if (b === undefined) {
|
|
153
|
-
// Held by head and not by base, so its endpoints resolve against head — the document
|
|
154
|
-
// that has the Symbols — and the question is whether base could have seen them.
|
|
155
|
-
const lostFiles = endpointsLostBy(dep, sides.head, sides.base)
|
|
156
|
-
if (lostFiles.length > 0) unknown.push({ dependency: dep, absentFrom: "base", lostFiles })
|
|
157
|
-
else added.push(dep)
|
|
158
|
-
continue
|
|
159
|
-
}
|
|
160
|
-
// A direction or effect flip. No loss check: both documents hold the edge, so neither is
|
|
161
|
-
// silent about it, and `unknown` exists only to explain a silence. That holds without
|
|
162
|
-
// appealing to any invariant — nothing forbids a path from being both a `source.file` and a
|
|
163
|
-
// skipped one, so "they both have it, therefore neither lost the file" would not be sound.
|
|
164
|
-
if (b.direction !== dep.direction || (b.effect ?? null) !== (dep.effect ?? null)) {
|
|
165
|
-
removed.push(b)
|
|
166
|
-
added.push(dep)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
for (const [key, dep] of baseKeys) {
|
|
170
|
-
if (headKeys.has(key)) continue
|
|
171
|
-
const lostFiles = endpointsLostBy(dep, sides.base, sides.head)
|
|
172
|
-
if (lostFiles.length > 0) unknown.push({ dependency: dep, absentFrom: "head", lostFiles })
|
|
173
|
-
else removed.push(dep)
|
|
174
|
-
}
|
|
175
|
-
added.sort(compareDependencies)
|
|
176
|
-
removed.sort(compareDependencies)
|
|
177
|
-
unknown.sort((a, b) => compareDependencies(a.dependency, b.dependency))
|
|
178
|
-
return { added, removed, unknown }
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* The endpoint files `absent` never analysed, read through `holder` because that is the
|
|
183
|
-
* document the edge — and therefore the Symbol behind each endpoint — actually comes from.
|
|
184
|
-
*
|
|
185
|
-
* Both endpoints are checked: an edge dies when *either* end's file goes, including one whose
|
|
186
|
-
* other end survived, and that half is the easiest to miss because the surviving Symbol is
|
|
187
|
-
* right there in both documents.
|
|
188
|
-
*
|
|
189
|
-
* Deduped and sorted by path, so an intra-file edge collapses to the one file it lost.
|
|
190
|
-
*/
|
|
191
|
-
function endpointsLostBy(
|
|
192
|
-
dep: Dependency,
|
|
193
|
-
holder: DependencySideView,
|
|
194
|
-
absent: DependencySideView,
|
|
195
|
-
): DiffSkippedFile[] {
|
|
196
|
-
// Keyed by path, so an intra-file edge collapses to the one file it lost. Keying by reason
|
|
197
|
-
// instead would look identical on every fixture whose two endpoints were skipped for
|
|
198
|
-
// different reasons, and silently drop one of two files skipped for the same reason.
|
|
199
|
-
const byPath = new Map<RelativePath, SkipReason>()
|
|
200
|
-
for (const endpoint of [dep.from, dep.to]) {
|
|
201
|
-
const file = holder.symbolFiles.get(endpoint)
|
|
202
|
-
// Normally a Component endpoint: an aggregate over roots, with no file to lose, which is
|
|
203
|
-
// why component-level edges need no special case to stay out of this.
|
|
204
|
-
//
|
|
205
|
-
// The other way to land here is a symbol-shaped endpoint with no Symbol behind it, which
|
|
206
|
-
// `ir-schema.md` §14 #4 forbids. `buildDiff` does not run the integrity checker, and the
|
|
207
|
-
// CLI cannot reach it because `readIR` rejects such a document first — so this is a library
|
|
208
|
-
// caller who assembled an IR by hand, and the edge quietly reverts to the misclassification
|
|
209
|
-
// this function exists to remove. Treated the same as a Component endpoint deliberately:
|
|
210
|
-
// there is no diagnostics channel here, and refusing would take down the legitimate case
|
|
211
|
-
// that shares the branch.
|
|
212
|
-
if (file === undefined) continue
|
|
213
|
-
const reason = absent.lostFiles.get(file)
|
|
214
|
-
if (reason === undefined) continue
|
|
215
|
-
byPath.set(file, reason)
|
|
216
|
-
}
|
|
217
|
-
return [...byPath.entries()]
|
|
218
|
-
.map(([path, reason]) => ({ path, reason }))
|
|
219
|
-
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* `docs/design/diff-algorithm.md` §6.2 — the fields Dependency identity is made of, in key order, and the join that turns
|
|
224
|
-
* them into one. Both halves are exported so the entry-point uniqueness check keys on
|
|
225
|
-
* exactly what this file keys on — a check with its own notion of identity would let through
|
|
226
|
-
* the duplicates that actually collide here, and a check that shared only the join would
|
|
227
|
-
* still be free to disagree about which fields identity is made of.
|
|
228
|
-
*
|
|
229
|
-
* Core's invariant #13 joins the same triple with a different separator. The two agree for
|
|
230
|
-
* every endpoint that satisfies the id grammars of ir-schema.md §3.1 and §4, which is every
|
|
231
|
-
* Document the integrity checker accepts; they could in principle disagree for one that does
|
|
232
|
-
* not, and `buildDiff` runs no grammar check on the IRs it is handed.
|
|
233
|
-
*/
|
|
234
|
-
export const DEPENDENCY_IDENTITY_FIELDS = ["from", "to", "via"] as const
|
|
235
|
-
|
|
236
|
-
export function dependencyIdentity(parts: readonly string[]): string {
|
|
237
|
-
return parts.join("::")
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function dependencyKey(d: Dependency): string {
|
|
241
|
-
return dependencyIdentity(DEPENDENCY_IDENTITY_FIELDS.map((field) => d[field]))
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function compareDependencies(a: Dependency, b: Dependency): number {
|
|
245
|
-
const keyA = dependencyKey(a)
|
|
246
|
-
const keyB = dependencyKey(b)
|
|
247
|
-
return keyA < keyB ? -1 : keyA > keyB ? 1 : 0
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function stringArraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
251
|
-
if (a.length !== b.length) return false
|
|
252
|
-
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
|
253
|
-
return true
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function sortById<T extends { id: string }>(items: T[]): void {
|
|
257
|
-
items.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
258
|
-
}
|
package/src/delta.ts
DELETED
|
@@ -1,400 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ArrayDelta,
|
|
3
|
-
Call,
|
|
4
|
-
Decorator,
|
|
5
|
-
Effect,
|
|
6
|
-
Symbol as IRSymbol,
|
|
7
|
-
Rule,
|
|
8
|
-
Signature,
|
|
9
|
-
SignatureDelta,
|
|
10
|
-
SymbolDelta,
|
|
11
|
-
} from "@aburi/types"
|
|
12
|
-
import { DiffError } from "./errors"
|
|
13
|
-
|
|
14
|
-
export const DEFAULT_LINE_FUZZ = 2
|
|
15
|
-
export const MAX_LINE_FUZZ = 10
|
|
16
|
-
export const MIN_LINE_FUZZ = 0
|
|
17
|
-
|
|
18
|
-
export interface DeltaOptions {
|
|
19
|
-
/**
|
|
20
|
-
* §5.2.1 line fuzz for rule/call identity. Must be an integer in
|
|
21
|
-
* `[MIN_LINE_FUZZ, MAX_LINE_FUZZ]` (`0..10`); anything outside — or a non-finite value —
|
|
22
|
-
* throws `DiffError({ code: "invalid-line-fuzz" })`. Setting `0` disables fuzz; omitting
|
|
23
|
-
* the field falls back to `DEFAULT_LINE_FUZZ` (2).
|
|
24
|
-
*/
|
|
25
|
-
lineFuzz?: number
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* §5 — Compute the full per-Symbol delta between two paired Symbols. The three axis
|
|
30
|
-
* booleans (`apiChanged` / `logicChanged` / `syntaxChanged`) come from fingerprint
|
|
31
|
-
* comparison; the array deltas (`rules` / `effects` / `calls` / `decorators`) come from
|
|
32
|
-
* identity-preserving diff with configurable line fuzz.
|
|
33
|
-
*/
|
|
34
|
-
export function computeSymbolDelta(
|
|
35
|
-
base: IRSymbol,
|
|
36
|
-
head: IRSymbol,
|
|
37
|
-
options: DeltaOptions = {},
|
|
38
|
-
): SymbolDelta {
|
|
39
|
-
const fuzz = validateLineFuzz(options.lineFuzz ?? DEFAULT_LINE_FUZZ)
|
|
40
|
-
const delta: SymbolDelta = {
|
|
41
|
-
apiChanged: base.fingerprint.api !== head.fingerprint.api,
|
|
42
|
-
logicChanged: base.fingerprint.logic !== head.fingerprint.logic,
|
|
43
|
-
syntaxChanged: base.fingerprint.syntax !== head.fingerprint.syntax,
|
|
44
|
-
componentChanged: (base.component ?? null) !== (head.component ?? null),
|
|
45
|
-
visibilityChanged: base.visibility !== head.visibility,
|
|
46
|
-
}
|
|
47
|
-
delta.rules = diffRules(base.rules, head.rules, fuzz)
|
|
48
|
-
delta.effects = diffEffects(base.effects, head.effects)
|
|
49
|
-
delta.calls = diffCalls(base.calls, head.calls, fuzz)
|
|
50
|
-
delta.decorators = diffDecorators(base.decorators, head.decorators, fuzz)
|
|
51
|
-
const sigDelta = diffSignature(base.signature ?? null, head.signature ?? null)
|
|
52
|
-
delta.signature = sigDelta
|
|
53
|
-
return delta
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* §5.2.1 range check. Loud on violation rather than silently clamping so a config typo
|
|
58
|
-
* (`lineFuzz: 999`) or an upstream numeric bug (`NaN`, `Infinity`) surfaces at the diff
|
|
59
|
-
* boundary instead of quietly rounding into a range that produces the wrong deltas.
|
|
60
|
-
*/
|
|
61
|
-
function validateLineFuzz(value: number): number {
|
|
62
|
-
if (!Number.isFinite(value) || !Number.isInteger(value)) {
|
|
63
|
-
throw new DiffError(
|
|
64
|
-
`config.diff.lineFuzz must be an integer in [${MIN_LINE_FUZZ}, ${MAX_LINE_FUZZ}]; got ${String(value)}.`,
|
|
65
|
-
{ code: "invalid-line-fuzz", value: String(value) },
|
|
66
|
-
)
|
|
67
|
-
}
|
|
68
|
-
if (value < MIN_LINE_FUZZ || value > MAX_LINE_FUZZ) {
|
|
69
|
-
throw new DiffError(
|
|
70
|
-
`config.diff.lineFuzz must be within [${MIN_LINE_FUZZ}, ${MAX_LINE_FUZZ}]; got ${value}.`,
|
|
71
|
-
{ code: "invalid-line-fuzz", value: String(value) },
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
return value
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
interface Identified<T> {
|
|
78
|
-
item: T
|
|
79
|
-
key: string
|
|
80
|
-
line: number
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* §5.2 — pair the two sides by identity key within ±`lineFuzz`, then classify each element
|
|
85
|
-
* into `added` / `removed` / `modified`. `modified` fires only when a pairing holds and the
|
|
86
|
-
* content differs, so a cosmetic line shift produces nothing.
|
|
87
|
-
*
|
|
88
|
-
* Several elements of one Symbol routinely share a key — two `guard` rules, two calls to one
|
|
89
|
-
* target, two `@Get` — so which base element a head element takes is a real choice. Two
|
|
90
|
-
* passes make it: first the elements whose key **and content** agree, then whatever is left.
|
|
91
|
-
* An untouched element is therefore claimed by its own counterpart before an edited or
|
|
92
|
-
* deleted neighbour can take it, and the remainder pairs by proximity — where a genuine edit
|
|
93
|
-
* lands. Taking the first key hit instead reported a deleted `guard@1` by removing its
|
|
94
|
-
* untouched `guard@3` neighbour *and* listing that same neighbour as modified.
|
|
95
|
-
*
|
|
96
|
-
* Each pass is an order-preserving assignment rather than a per-element search, because a
|
|
97
|
-
* greedy pass takes a pairing that leaves a better set unreachable. Two identical guards
|
|
98
|
-
* shifted down two lines are the smallest case: the first head element is nearest to the
|
|
99
|
-
* *second* base element, and claiming it strands the other head outside the window, so a
|
|
100
|
-
* block that moved intact comes back as an `added` and a `removed`.
|
|
101
|
-
*/
|
|
102
|
-
function differentiate<T>(
|
|
103
|
-
base: readonly Identified<T>[],
|
|
104
|
-
head: readonly Identified<T>[],
|
|
105
|
-
isEqual: (a: T, b: T) => boolean,
|
|
106
|
-
lineFuzz: number,
|
|
107
|
-
): ArrayDelta {
|
|
108
|
-
const freeBase = new Set(base.map((_, index) => index))
|
|
109
|
-
const freeHead = new Set(head.map((_, index) => index))
|
|
110
|
-
const partnerOf = new Map<number, Identified<T>>()
|
|
111
|
-
for (const contentMustAgree of [true, false]) {
|
|
112
|
-
const admits = (b: Identified<T>, h: Identified<T>): boolean =>
|
|
113
|
-
b.key === h.key &&
|
|
114
|
-
Math.abs(b.line - h.line) <= lineFuzz &&
|
|
115
|
-
(!contentMustAgree || isEqual(b.item, h.item))
|
|
116
|
-
for (const [baseIndex, headIndex] of assignInOrder(base, head, freeBase, freeHead, admits)) {
|
|
117
|
-
freeBase.delete(baseIndex)
|
|
118
|
-
freeHead.delete(headIndex)
|
|
119
|
-
const counterpart = base[baseIndex]
|
|
120
|
-
if (counterpart !== undefined) partnerOf.set(headIndex, counterpart)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const added: T[] = []
|
|
125
|
-
const modified: T[] = []
|
|
126
|
-
for (const [index, h] of head.entries()) {
|
|
127
|
-
const counterpart = partnerOf.get(index)
|
|
128
|
-
if (counterpart === undefined) added.push(h.item)
|
|
129
|
-
else if (!isEqual(counterpart.item, h.item)) modified.push(h.item)
|
|
130
|
-
}
|
|
131
|
-
const removed = base.filter((_, index) => freeBase.has(index)).map((b) => b.item)
|
|
132
|
-
return { added, removed, modified }
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** How good an assignment is: more pairings first, then less total line movement. */
|
|
136
|
-
interface Quality {
|
|
137
|
-
pairs: number
|
|
138
|
-
distance: number
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const NO_PAIRINGS: Quality = { pairs: 0, distance: 0 }
|
|
142
|
-
|
|
143
|
-
/** `a` is preferred over `b`: more pairings wins, and among equals the tighter one does. */
|
|
144
|
-
function better(a: Quality, b: Quality): boolean {
|
|
145
|
-
return a.pairs !== b.pairs ? a.pairs > b.pairs : a.distance < b.distance
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* The best set of non-crossing pairings between the still-free elements of `base` and `head`,
|
|
150
|
-
* as `[baseIndex, headIndex]` in ascending order — most pairings first, then least total line
|
|
151
|
-
* movement.
|
|
152
|
-
*
|
|
153
|
-
* Non-crossing is the whole content of the rule, and ir-schema §14 #11 is what licenses it:
|
|
154
|
-
* these arrays are ordered by line, so `i < j` means element `i` sits above element `j` in the
|
|
155
|
-
* file. Two pairings that cross would have an element move above one it was below, which is
|
|
156
|
-
* not a line shift — it is a different element. Restricting to non-crossing sets also makes
|
|
157
|
-
* the optimum reachable by a suffix recurrence rather than by a general assignment algorithm.
|
|
158
|
-
*
|
|
159
|
-
* Maximising the count before minimising distance is what stops a near pairing from being
|
|
160
|
-
* taken at the cost of a far one that would otherwise have no partner at all.
|
|
161
|
-
*/
|
|
162
|
-
function assignInOrder<T>(
|
|
163
|
-
base: readonly Identified<T>[],
|
|
164
|
-
head: readonly Identified<T>[],
|
|
165
|
-
freeBase: ReadonlySet<number>,
|
|
166
|
-
freeHead: ReadonlySet<number>,
|
|
167
|
-
admits: (b: Identified<T>, h: Identified<T>) => boolean,
|
|
168
|
-
): Array<[number, number]> {
|
|
169
|
-
// best[i][j] is the quality of the best assignment over base[i..] and head[j..].
|
|
170
|
-
const best: Quality[][] = Array.from({ length: base.length + 1 }, () =>
|
|
171
|
-
Array.from({ length: head.length + 1 }, () => NO_PAIRINGS),
|
|
172
|
-
)
|
|
173
|
-
const pairingAt = (i: number, j: number): Quality | null => {
|
|
174
|
-
const b = base[i]
|
|
175
|
-
const h = head[j]
|
|
176
|
-
if (b === undefined || h === undefined) return null
|
|
177
|
-
if (!freeBase.has(i) || !freeHead.has(j) || !admits(b, h)) return null
|
|
178
|
-
const rest = best[i + 1]?.[j + 1] ?? NO_PAIRINGS
|
|
179
|
-
return { pairs: rest.pairs + 1, distance: rest.distance + Math.abs(b.line - h.line) }
|
|
180
|
-
}
|
|
181
|
-
for (let i = base.length - 1; i >= 0; i--) {
|
|
182
|
-
for (let j = head.length - 1; j >= 0; j--) {
|
|
183
|
-
const skipBase = best[i + 1]?.[j] ?? NO_PAIRINGS
|
|
184
|
-
const skipHead = best[i]?.[j + 1] ?? NO_PAIRINGS
|
|
185
|
-
let winner = better(skipBase, skipHead) ? skipBase : skipHead
|
|
186
|
-
const paired = pairingAt(i, j)
|
|
187
|
-
if (paired !== null && better(paired, winner)) winner = paired
|
|
188
|
-
const row = best[i]
|
|
189
|
-
if (row !== undefined) row[j] = winner
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Walk the table back down, taking a pairing wherever it is what the optimum was built from.
|
|
194
|
-
const chosen: Array<[number, number]> = []
|
|
195
|
-
let i = 0
|
|
196
|
-
let j = 0
|
|
197
|
-
while (i < base.length && j < head.length) {
|
|
198
|
-
const here = best[i]?.[j] ?? NO_PAIRINGS
|
|
199
|
-
const paired = pairingAt(i, j)
|
|
200
|
-
if (paired !== null && paired.pairs === here.pairs && paired.distance === here.distance) {
|
|
201
|
-
chosen.push([i, j])
|
|
202
|
-
i++
|
|
203
|
-
j++
|
|
204
|
-
continue
|
|
205
|
-
}
|
|
206
|
-
const skipBase = best[i + 1]?.[j] ?? NO_PAIRINGS
|
|
207
|
-
if (skipBase.pairs === here.pairs && skipBase.distance === here.distance) i++
|
|
208
|
-
else j++
|
|
209
|
-
}
|
|
210
|
-
return chosen
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function diffRules(base: readonly Rule[], head: readonly Rule[], lineFuzz: number): ArrayDelta {
|
|
214
|
-
const mapper = (r: Rule): Identified<Rule> => ({
|
|
215
|
-
item: r,
|
|
216
|
-
key: r.type,
|
|
217
|
-
line: r.line,
|
|
218
|
-
})
|
|
219
|
-
return differentiate(base.map(mapper), head.map(mapper), rulesEqual, lineFuzz)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function rulesEqual(a: Rule, b: Rule): boolean {
|
|
223
|
-
return (
|
|
224
|
-
a.type === b.type &&
|
|
225
|
-
(a.condition ?? null) === (b.condition ?? null) &&
|
|
226
|
-
(a.what ?? null) === (b.what ?? null) &&
|
|
227
|
-
(a.expr ?? null) === (b.expr ?? null) &&
|
|
228
|
-
(a.loopKind ?? null) === (b.loopKind ?? null)
|
|
229
|
-
)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function diffEffects(base: readonly Effect[], head: readonly Effect[]): ArrayDelta {
|
|
233
|
-
const mapper = (e: Effect): Identified<Effect> => ({
|
|
234
|
-
item: e,
|
|
235
|
-
key: `${e.id}::${e.target}`,
|
|
236
|
-
// Propagated entries (effect-propagation.md §5.1) omit `line`. An infinite fuzz admits
|
|
237
|
-
// every same-key candidate, so no placeholder can put an effect outside the window — but
|
|
238
|
-
// §5.2.0 ranks the admitted candidates by distance, so `0` does read as "at the top of
|
|
239
|
-
// the Symbol" and a propagated effect prefers the earliest local one carrying its key.
|
|
240
|
-
// Effects are already keyed by `(id, target)`, which is the whole of their identity in
|
|
241
|
-
// ir-schema §7, so a tie only arises between two entries that agree on it; the choice
|
|
242
|
-
// between those is what §5.2.0's exact-content pass settles.
|
|
243
|
-
line: e.line ?? 0,
|
|
244
|
-
})
|
|
245
|
-
return differentiate(base.map(mapper), head.map(mapper), effectsEqual, Number.POSITIVE_INFINITY)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function effectsEqual(a: Effect, b: Effect): boolean {
|
|
249
|
-
return (
|
|
250
|
-
a.id === b.id && a.target === b.target && a.plugin === b.plugin && a.confidence === b.confidence
|
|
251
|
-
)
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function diffCalls(base: readonly Call[], head: readonly Call[], lineFuzz: number): ArrayDelta {
|
|
255
|
-
const mapper = (c: Call): Identified<Call> => ({
|
|
256
|
-
item: c,
|
|
257
|
-
key: c.target,
|
|
258
|
-
line: c.line,
|
|
259
|
-
})
|
|
260
|
-
return differentiate(base.map(mapper), head.map(mapper), callsEqual, lineFuzz)
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function callsEqual(a: Call, b: Call): boolean {
|
|
264
|
-
return a.target === b.target && (a.resolved ?? null) === (b.resolved ?? null)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* §5.2.2 — decorator diff. Identity is `name`; when the same-named decorator is present
|
|
269
|
-
* on both sides within the line-fuzz window, the argument list determines whether the
|
|
270
|
-
* entry is a modified pair or an implicit no-op.
|
|
271
|
-
*/
|
|
272
|
-
function diffDecorators(
|
|
273
|
-
base: readonly Decorator[],
|
|
274
|
-
head: readonly Decorator[],
|
|
275
|
-
lineFuzz: number,
|
|
276
|
-
): ArrayDelta {
|
|
277
|
-
const mapper = (d: Decorator): Identified<Decorator> => ({
|
|
278
|
-
item: d,
|
|
279
|
-
key: d.name,
|
|
280
|
-
line: d.line,
|
|
281
|
-
})
|
|
282
|
-
return differentiate(base.map(mapper), head.map(mapper), decoratorsEqual, lineFuzz)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function decoratorsEqual(a: Decorator, b: Decorator): boolean {
|
|
286
|
-
if (a.name !== b.name) return false
|
|
287
|
-
if (a.arguments.length !== b.arguments.length) return false
|
|
288
|
-
for (let i = 0; i < a.arguments.length; i++) {
|
|
289
|
-
if (a.arguments[i] !== b.arguments[i]) return false
|
|
290
|
-
}
|
|
291
|
-
return true
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* §5.3 — signature delta. Three cases:
|
|
296
|
-
* - both sides `null` → `null` (nothing to compare)
|
|
297
|
-
* - exactly one side `null` → the present side is emitted verbatim as
|
|
298
|
-
* `added` (when only head has a signature) or `removed` (when only base has one);
|
|
299
|
-
* the three axis booleans compare the missing side against `false` defaults
|
|
300
|
-
* - both sides non-null → per-list sub-deltas:
|
|
301
|
-
* - `inputs`: strict positional compare via `differentiate(..., lineFuzz: 0)` — index
|
|
302
|
-
* used as the line key so ordered parameter lists match position-for-position
|
|
303
|
-
* - `outputs`: positional compare (added/removed emitted when the arrays diverge at
|
|
304
|
-
* an index), no `modified` category
|
|
305
|
-
* - `throws`: unordered set diff — duplicates collapse per Set semantics
|
|
306
|
-
* - `async` / `generator` / `typeParameters` change flags follow the raw fields
|
|
307
|
-
*/
|
|
308
|
-
function diffSignature(base: Signature | null, head: Signature | null): SignatureDelta | null {
|
|
309
|
-
if (base === null && head === null) return null
|
|
310
|
-
if (base === null || head === null) {
|
|
311
|
-
const empty: ArrayDelta = { added: [], removed: [], modified: [] }
|
|
312
|
-
const present = base ?? head
|
|
313
|
-
if (present === null) return null
|
|
314
|
-
const isBaseNull = base === null
|
|
315
|
-
const inputs: ArrayDelta = isBaseNull
|
|
316
|
-
? { added: [...present.inputs], removed: [], modified: [] }
|
|
317
|
-
: { added: [], removed: [...present.inputs], modified: [] }
|
|
318
|
-
const outputs: ArrayDelta = isBaseNull
|
|
319
|
-
? { added: [...present.outputs], removed: [], modified: [] }
|
|
320
|
-
: { added: [], removed: [...present.outputs], modified: [] }
|
|
321
|
-
const throws: ArrayDelta = isBaseNull
|
|
322
|
-
? { added: [...present.throws], removed: [], modified: [] }
|
|
323
|
-
: { added: [], removed: [...present.throws], modified: [] }
|
|
324
|
-
return {
|
|
325
|
-
inputs,
|
|
326
|
-
outputs,
|
|
327
|
-
throws,
|
|
328
|
-
asyncChanged: (base?.async ?? false) !== (head?.async ?? false),
|
|
329
|
-
generatorChanged: (base?.generator ?? false) !== (head?.generator ?? false),
|
|
330
|
-
typeParametersChanged: !arraysEqual(
|
|
331
|
-
base?.typeParameters ?? empty.added.map(String),
|
|
332
|
-
head?.typeParameters ?? empty.added.map(String),
|
|
333
|
-
),
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* Parameters are positional, so the index is part of the identity and the fuzz below is 0.
|
|
338
|
-
* That makes each key unique within its list, so §5.2.0 never has a choice to make here and
|
|
339
|
-
* both of its passes reduce to the same positional comparison. Dropping the index from the
|
|
340
|
-
* key would make two same-named parameters ambiguous and hand the choice back to §5.2.0,
|
|
341
|
-
* which is not what a positional list wants.
|
|
342
|
-
*/
|
|
343
|
-
const inputMapper = (
|
|
344
|
-
input: { name: string; type: string },
|
|
345
|
-
index: number,
|
|
346
|
-
): Identified<{ name: string; type: string }> => ({
|
|
347
|
-
item: input,
|
|
348
|
-
key: `${index}:${input.name}`,
|
|
349
|
-
line: index,
|
|
350
|
-
})
|
|
351
|
-
const inputs = differentiate(
|
|
352
|
-
base.inputs.map(inputMapper),
|
|
353
|
-
head.inputs.map(inputMapper),
|
|
354
|
-
(a, b) => a.name === b.name && a.type === b.type,
|
|
355
|
-
0,
|
|
356
|
-
)
|
|
357
|
-
const outputs = diffStringList(base.outputs, head.outputs)
|
|
358
|
-
const throws = diffStringSet(base.throws, head.throws)
|
|
359
|
-
return {
|
|
360
|
-
inputs,
|
|
361
|
-
outputs,
|
|
362
|
-
throws,
|
|
363
|
-
asyncChanged: base.async !== head.async,
|
|
364
|
-
generatorChanged: base.generator !== head.generator,
|
|
365
|
-
typeParametersChanged: !arraysEqual(base.typeParameters, head.typeParameters),
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function diffStringList(base: readonly string[], head: readonly string[]): ArrayDelta {
|
|
370
|
-
const added: string[] = []
|
|
371
|
-
const removed: string[] = []
|
|
372
|
-
const max = Math.max(base.length, head.length)
|
|
373
|
-
for (let i = 0; i < max; i++) {
|
|
374
|
-
const b = i < base.length ? base[i] : undefined
|
|
375
|
-
const h = i < head.length ? head[i] : undefined
|
|
376
|
-
if (b === undefined && h !== undefined) added.push(h)
|
|
377
|
-
else if (h === undefined && b !== undefined) removed.push(b)
|
|
378
|
-
else if (b !== h) {
|
|
379
|
-
if (b !== undefined) removed.push(b)
|
|
380
|
-
if (h !== undefined) added.push(h)
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
return { added, removed, modified: [] }
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function diffStringSet(base: readonly string[], head: readonly string[]): ArrayDelta {
|
|
387
|
-
const setB = new Set(base)
|
|
388
|
-
const setH = new Set(head)
|
|
389
|
-
const added: string[] = []
|
|
390
|
-
const removed: string[] = []
|
|
391
|
-
for (const h of setH) if (!setB.has(h)) added.push(h)
|
|
392
|
-
for (const b of setB) if (!setH.has(b)) removed.push(b)
|
|
393
|
-
return { added, removed, modified: [] }
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
397
|
-
if (a.length !== b.length) return false
|
|
398
|
-
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
|
399
|
-
return true
|
|
400
|
-
}
|