@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/src/components.ts CHANGED
@@ -1,10 +1,27 @@
1
- import type { Component, ComponentDiff, Dependency, DependencyDiff } from "@aburi/types"
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"
2
14
 
3
15
  /**
4
- * §6.1 — Component diff. Component identity is `id`; when a Component is present in both
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
5
22
  * base and head with the same id, the three delta booleans describe which axes actually
6
23
  * moved (roots reshuffle, public API globs, or framework hint list). `modified` deltas
7
- * are intentionally absent from this diff (§5.2.3): fields are reported as before/after
24
+ * are intentionally absent from this diff (diff-algorithm.md §5.2.3): fields are reported as before/after
8
25
  * pairs via the `changed[]` entries so consumers can render them without stringifying
9
26
  * arrays.
10
27
  */
@@ -12,9 +29,9 @@ export function diffComponents(
12
29
  base: readonly Component[],
13
30
  head: readonly Component[],
14
31
  ): ComponentDiff {
15
- const baseById = new Map<string, Component>()
32
+ const baseById = new Map<ComponentId, Component>()
16
33
  for (const c of base) baseById.set(c.id, c)
17
- const headById = new Map<string, Component>()
34
+ const headById = new Map<ComponentId, Component>()
18
35
  for (const c of head) headById.set(c.id, c)
19
36
 
20
37
  const added: Component[] = []
@@ -51,41 +68,177 @@ export function diffComponents(
51
68
  }
52
69
 
53
70
  /**
54
- * §6.2 — Dependency diff. Identity is the composite `(from, to, via)` triple; direction
55
- * and effect changes are surfaced as an added + removed pair so `modified` is not part of
56
- * the schema (§6.2 tail).
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.
57
137
  */
58
138
  export function diffDependencies(
59
139
  base: readonly Dependency[],
60
140
  head: readonly Dependency[],
61
- ): DependencyDiff {
141
+ sides: { base: DependencySideView; head: DependencySideView },
142
+ ): DependencyDiff & { unknown: DependencyUnknown[] } {
62
143
  const baseKeys = new Map<string, Dependency>()
63
144
  for (const d of base) baseKeys.set(dependencyKey(d), d)
64
145
  const headKeys = new Map<string, Dependency>()
65
146
  for (const d of head) headKeys.set(dependencyKey(d), d)
66
147
  const added: Dependency[] = []
67
148
  const removed: Dependency[] = []
149
+ const unknown: DependencyUnknown[] = []
68
150
  for (const [key, dep] of headKeys) {
69
151
  const b = baseKeys.get(key)
70
152
  if (b === undefined) {
71
- added.push(dep)
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)
72
158
  continue
73
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.
74
164
  if (b.direction !== dep.direction || (b.effect ?? null) !== (dep.effect ?? null)) {
75
165
  removed.push(b)
76
166
  added.push(dep)
77
167
  }
78
168
  }
79
169
  for (const [key, dep] of baseKeys) {
80
- if (!headKeys.has(key)) removed.push(dep)
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)
81
174
  }
82
175
  added.sort(compareDependencies)
83
176
  removed.sort(compareDependencies)
84
- return { added, removed }
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("::")
85
238
  }
86
239
 
87
240
  function dependencyKey(d: Dependency): string {
88
- return `${d.from}::${d.to}::${d.via}`
241
+ return dependencyIdentity(DEPENDENCY_IDENTITY_FIELDS.map((field) => d[field]))
89
242
  }
90
243
 
91
244
  function compareDependencies(a: Dependency, b: Dependency): number {
package/src/delta.ts CHANGED
@@ -81,10 +81,23 @@ interface Identified<T> {
81
81
  }
82
82
 
83
83
  /**
84
- * Two-index diff: build key-indexed maps for both sides, then classify each key into
85
- * `added` / `removed` / `modified`. Line-fuzzed identities are matched within tolerance
86
- * so cosmetic line shifts do not produce added/removed noise. `modified` only fires when
87
- * the identity key stays the same but the content differs.
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`.
88
101
  */
89
102
  function differentiate<T>(
90
103
  base: readonly Identified<T>[],
@@ -92,37 +105,109 @@ function differentiate<T>(
92
105
  isEqual: (a: T, b: T) => boolean,
93
106
  lineFuzz: number,
94
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
+
95
124
  const added: T[] = []
96
- const removed: T[] = []
97
125
  const modified: T[] = []
98
- const consumedBase = new Set<number>()
99
- for (const h of head) {
100
- let matchIdx = -1
101
- for (let j = 0; j < base.length; j++) {
102
- if (consumedBase.has(j)) continue
103
- const b = base[j]
104
- if (b === undefined) continue
105
- if (b.key !== h.key) continue
106
- if (Math.abs(b.line - h.line) > lineFuzz) continue
107
- matchIdx = j
108
- break
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
109
190
  }
110
- if (matchIdx === -1) {
111
- added.push(h.item)
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++
112
204
  continue
113
205
  }
114
- consumedBase.add(matchIdx)
115
- const bmatch = base[matchIdx]
116
- if (bmatch === undefined) continue
117
- if (!isEqual(bmatch.item, h.item)) modified.push(h.item)
118
- }
119
- for (let j = 0; j < base.length; j++) {
120
- if (consumedBase.has(j)) continue
121
- const b = base[j]
122
- if (b === undefined) continue
123
- removed.push(b.item)
206
+ const skipBase = best[i + 1]?.[j] ?? NO_PAIRINGS
207
+ if (skipBase.pairs === here.pairs && skipBase.distance === here.distance) i++
208
+ else j++
124
209
  }
125
- return { added, removed, modified }
210
+ return chosen
126
211
  }
127
212
 
128
213
  function diffRules(base: readonly Rule[], head: readonly Rule[], lineFuzz: number): ArrayDelta {
@@ -148,7 +233,14 @@ function diffEffects(base: readonly Effect[], head: readonly Effect[]): ArrayDel
148
233
  const mapper = (e: Effect): Identified<Effect> => ({
149
234
  item: e,
150
235
  key: `${e.id}::${e.target}`,
151
- line: e.line,
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,
152
244
  })
153
245
  return differentiate(base.map(mapper), head.map(mapper), effectsEqual, Number.POSITIVE_INFINITY)
154
246
  }
@@ -241,6 +333,13 @@ function diffSignature(base: Signature | null, head: Signature | null): Signatur
241
333
  ),
242
334
  }
243
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
+ */
244
343
  const inputMapper = (
245
344
  input: { name: string; type: string },
246
345
  index: number,