@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/diff.ts
DELETED
|
@@ -1,494 +0,0 @@
|
|
|
1
|
-
import { reconstructCallEdgesFromIR, type SerializeOptions, serializeCanonical } from "@aburi/core"
|
|
2
|
-
import type {
|
|
3
|
-
DiffResult,
|
|
4
|
-
IR,
|
|
5
|
-
IRRef,
|
|
6
|
-
NotComparedFile,
|
|
7
|
-
RelativePath,
|
|
8
|
-
SkipReason,
|
|
9
|
-
Summary,
|
|
10
|
-
SymbolChange,
|
|
11
|
-
} from "@aburi/types"
|
|
12
|
-
import {
|
|
13
|
-
DEPENDENCY_IDENTITY_FIELDS,
|
|
14
|
-
dependencyIdentity,
|
|
15
|
-
dependencySideView,
|
|
16
|
-
diffComponents,
|
|
17
|
-
diffDependencies,
|
|
18
|
-
} from "./components"
|
|
19
|
-
import { computeSymbolDelta, type DeltaOptions } from "./delta"
|
|
20
|
-
import { DiffError } from "./errors"
|
|
21
|
-
import {
|
|
22
|
-
type GitRenameMap,
|
|
23
|
-
matchStageDroppedWeak,
|
|
24
|
-
matchStageGitRename,
|
|
25
|
-
matchStageId,
|
|
26
|
-
matchStageLogicFingerprint,
|
|
27
|
-
matchStageNameSignature,
|
|
28
|
-
type SymbolPair,
|
|
29
|
-
} from "./match"
|
|
30
|
-
import { computeSlices } from "./slice"
|
|
31
|
-
import { classifyStatus, dropDirection } from "./status"
|
|
32
|
-
|
|
33
|
-
const DIFF_SCHEMA = "https://aburi.kage1020.com/schema/aburi.diff.v1.json"
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The two counters `buildDiff` always writes, narrowed off `Summary`'s optionals.
|
|
37
|
-
*
|
|
38
|
-
* They are optional on the wire so a diff written before they existed stays valid, and that
|
|
39
|
-
* optionality is about *documents*, not about this function — a caller holding a value it just
|
|
40
|
-
* built should not have to re-decide what "absent" would have meant. Same reason
|
|
41
|
-
* `diffDependencies` declares its `unknown` array present.
|
|
42
|
-
*/
|
|
43
|
-
interface UnknownCounters {
|
|
44
|
-
unknown: number
|
|
45
|
-
depsUnknown: number
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface DiffInput {
|
|
49
|
-
baseIR: IR
|
|
50
|
-
headIR: IR
|
|
51
|
-
/** IR reference metadata (git ref / file path) for provenance. */
|
|
52
|
-
base: IRRef
|
|
53
|
-
head: IRRef
|
|
54
|
-
/** Generator record for the diff output. Defaults to `{name: "aburi", version: "0.0.0"}`. */
|
|
55
|
-
generator?: { name: string; version: string }
|
|
56
|
-
/** Optional stage-2 rename table. When null/undefined stage 2 is skipped. */
|
|
57
|
-
gitRenames?: GitRenameMap | null
|
|
58
|
-
/** Passed through to computeSymbolDelta (§5.2.1 line fuzz). */
|
|
59
|
-
delta?: DeltaOptions
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const DEFAULT_GENERATOR = { name: "aburi", version: "0.0.0" }
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Top-level entry: run the 5-stage matcher, classify each pair, produce array deltas,
|
|
66
|
-
* fold in Component / Dependency diffs, and assemble the `aburi.diff.v1` JSON projection.
|
|
67
|
-
* The function is pure; write-to-disk is delegated to `writeCanonicalDiff` so callers can
|
|
68
|
-
* pipe the result through additional steps (Markdown projection, `--fail-on` gate) before
|
|
69
|
-
* serialisation.
|
|
70
|
-
*/
|
|
71
|
-
export function buildDiff(
|
|
72
|
-
input: DiffInput,
|
|
73
|
-
): DiffResult & { notCompared: NotComparedFile[]; summary: Summary & UnknownCounters } {
|
|
74
|
-
assertDiffable(input.baseIR, "baseIR")
|
|
75
|
-
assertDiffable(input.headIR, "headIR")
|
|
76
|
-
ensureSchemasAgree(input.baseIR, input.headIR)
|
|
77
|
-
const stage1 = matchStageId(input.baseIR.symbols, input.headIR.symbols)
|
|
78
|
-
const stage2 = matchStageGitRename(
|
|
79
|
-
stage1.remainingBase,
|
|
80
|
-
stage1.remainingHead,
|
|
81
|
-
input.gitRenames ?? null,
|
|
82
|
-
)
|
|
83
|
-
const stage3 = matchStageLogicFingerprint(stage2.remainingBase, stage2.remainingHead)
|
|
84
|
-
const stage4 = matchStageNameSignature(stage3.remainingBase, stage3.remainingHead)
|
|
85
|
-
const stage4_5 = matchStageDroppedWeak(stage4.remainingBase, stage4.remainingHead)
|
|
86
|
-
|
|
87
|
-
const pairs: SymbolPair[] = [
|
|
88
|
-
...stage1.matched,
|
|
89
|
-
...stage2.matched,
|
|
90
|
-
...stage3.matched,
|
|
91
|
-
...stage4.matched,
|
|
92
|
-
...stage4_5.matched,
|
|
93
|
-
]
|
|
94
|
-
|
|
95
|
-
// Typed with the two counters present. They are optional on the wire, but this function
|
|
96
|
-
// writes both on every diff, and the local type is what carries that from here to the
|
|
97
|
-
// return without a cast.
|
|
98
|
-
const summary: Summary & UnknownCounters = {
|
|
99
|
-
unknown: 0,
|
|
100
|
-
depsUnknown: 0,
|
|
101
|
-
added: 0,
|
|
102
|
-
removed: 0,
|
|
103
|
-
moved: 0,
|
|
104
|
-
movedChanged: 0,
|
|
105
|
-
changed: 0,
|
|
106
|
-
droppedToggled: 0,
|
|
107
|
-
unchanged: 0,
|
|
108
|
-
droppedAdded: 0,
|
|
109
|
-
droppedRemoved: 0,
|
|
110
|
-
componentsAdded: 0,
|
|
111
|
-
componentsRemoved: 0,
|
|
112
|
-
componentsChanged: 0,
|
|
113
|
-
depsAdded: 0,
|
|
114
|
-
depsRemoved: 0,
|
|
115
|
-
}
|
|
116
|
-
// Counted locally and assigned once below, where the other totals are, so the increment
|
|
117
|
-
// sites do not have to reason about a counter that is optional on the wire.
|
|
118
|
-
let unknown = 0
|
|
119
|
-
|
|
120
|
-
const symbols: SymbolChange[] = []
|
|
121
|
-
for (const pair of pairs) {
|
|
122
|
-
const status = classifyStatus(pair.base, pair.head)
|
|
123
|
-
if (status === "unchanged") {
|
|
124
|
-
summary.unchanged++
|
|
125
|
-
continue
|
|
126
|
-
}
|
|
127
|
-
if (status === "dropped-toggled") {
|
|
128
|
-
summary.droppedToggled++
|
|
129
|
-
symbols.push({
|
|
130
|
-
status: "dropped-toggled",
|
|
131
|
-
before: pair.base,
|
|
132
|
-
after: pair.head,
|
|
133
|
-
direction: dropDirection(pair.head),
|
|
134
|
-
})
|
|
135
|
-
continue
|
|
136
|
-
}
|
|
137
|
-
if (status === "moved") {
|
|
138
|
-
summary.moved++
|
|
139
|
-
symbols.push({
|
|
140
|
-
status: "moved",
|
|
141
|
-
before: pair.base,
|
|
142
|
-
after: pair.head,
|
|
143
|
-
rationale: pair.rationale,
|
|
144
|
-
})
|
|
145
|
-
continue
|
|
146
|
-
}
|
|
147
|
-
if (status === "changed") {
|
|
148
|
-
summary.changed++
|
|
149
|
-
symbols.push({
|
|
150
|
-
status: "changed",
|
|
151
|
-
before: pair.base,
|
|
152
|
-
after: pair.head,
|
|
153
|
-
delta: computeSymbolDelta(pair.base, pair.head, input.delta ?? {}),
|
|
154
|
-
})
|
|
155
|
-
continue
|
|
156
|
-
}
|
|
157
|
-
// moved+changed
|
|
158
|
-
summary.movedChanged++
|
|
159
|
-
symbols.push({
|
|
160
|
-
status: "moved+changed",
|
|
161
|
-
before: pair.base,
|
|
162
|
-
after: pair.head,
|
|
163
|
-
rationale: pair.rationale,
|
|
164
|
-
delta: computeSymbolDelta(pair.base, pair.head, input.delta ?? {}),
|
|
165
|
-
})
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const finalRemainingHead = stage4_5.remainingHead
|
|
169
|
-
const finalRemainingBase = stage4_5.remainingBase
|
|
170
|
-
|
|
171
|
-
// Read after the five matching stages, never before them. A Symbol that crossed files
|
|
172
|
-
// between the two revisions is paired by stage 2, 3 or 4 and comes out `moved` or
|
|
173
|
-
// `moved+changed`, whichever end of the move sits in the lost file — the other document
|
|
174
|
-
// holds real evidence for it either way. Only the leftovers are absences, and only an
|
|
175
|
-
// absence in a file that was never analysed is unexplained.
|
|
176
|
-
//
|
|
177
|
-
// The two loops below read opposite ends: a base leftover is looked up by the file it
|
|
178
|
-
// came from, a head leftover by the file it arrived in. In both cases the question is the
|
|
179
|
-
// same — did the document that lacks this Symbol ever read the file it is in?
|
|
180
|
-
// One construction per side, shared with the Dependency diff below. The Symbol loops and
|
|
181
|
-
// `diffDependencies` therefore read the same two maps rather than two answers to the same
|
|
182
|
-
// question — which is what makes "a Symbol reported unknown and the edges it took with it
|
|
183
|
-
// cannot disagree about which file went missing" structural instead of aspirational.
|
|
184
|
-
const baseSide = dependencySideView(input.baseIR)
|
|
185
|
-
const headSide = dependencySideView(input.headIR)
|
|
186
|
-
const lostByHead = headSide.lostFiles
|
|
187
|
-
const lostByBase = baseSide.lostFiles
|
|
188
|
-
|
|
189
|
-
for (const h of finalRemainingHead) {
|
|
190
|
-
if (h.dropped) {
|
|
191
|
-
summary.droppedAdded++
|
|
192
|
-
continue
|
|
193
|
-
}
|
|
194
|
-
const reason = lostByBase.get(h.source.file)
|
|
195
|
-
if (reason !== undefined) {
|
|
196
|
-
unknown++
|
|
197
|
-
symbols.push({ status: "unknown", symbol: h, absentFrom: "base", reason })
|
|
198
|
-
continue
|
|
199
|
-
}
|
|
200
|
-
summary.added++
|
|
201
|
-
symbols.push({ status: "added", symbol: h })
|
|
202
|
-
}
|
|
203
|
-
for (const b of finalRemainingBase) {
|
|
204
|
-
if (b.dropped) {
|
|
205
|
-
summary.droppedRemoved++
|
|
206
|
-
continue
|
|
207
|
-
}
|
|
208
|
-
const reason = lostByHead.get(b.source.file)
|
|
209
|
-
if (reason !== undefined) {
|
|
210
|
-
unknown++
|
|
211
|
-
symbols.push({ status: "unknown", symbol: b, absentFrom: "head", reason })
|
|
212
|
-
continue
|
|
213
|
-
}
|
|
214
|
-
summary.removed++
|
|
215
|
-
symbols.push({ status: "removed", symbol: b })
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const components = diffComponents(input.baseIR.components, input.headIR.components)
|
|
219
|
-
summary.componentsAdded = components.added.length
|
|
220
|
-
summary.componentsRemoved = components.removed.length
|
|
221
|
-
summary.componentsChanged = components.changed.length
|
|
222
|
-
|
|
223
|
-
const dependencies = diffDependencies(input.baseIR.dependencies, input.headIR.dependencies, {
|
|
224
|
-
base: baseSide,
|
|
225
|
-
head: headSide,
|
|
226
|
-
})
|
|
227
|
-
summary.depsAdded = dependencies.added.length
|
|
228
|
-
summary.depsRemoved = dependencies.removed.length
|
|
229
|
-
// No `?? 0`: `diffDependencies` declares `unknown` present when it is given side views, so
|
|
230
|
-
// absorbing an absence here would launder a mis-wiring into a confident `depsUnknown: 0`.
|
|
231
|
-
summary.depsUnknown = dependencies.unknown.length
|
|
232
|
-
summary.unknown = unknown
|
|
233
|
-
|
|
234
|
-
symbols.sort(compareSymbolChange)
|
|
235
|
-
|
|
236
|
-
// Slice View clustering (docs/design/slice-view.md §2). Runs after status /
|
|
237
|
-
// delta computation and before Markdown projection. Consumes the resolved
|
|
238
|
-
// CallEdge[] reconstructed from each IR — §5.4 requires only resolved edges,
|
|
239
|
-
// never `Symbol.calls[]` directly. Emits `slices[]` unconditionally (§11.2);
|
|
240
|
-
// the Markdown side omits the section when the array is empty (§12.5).
|
|
241
|
-
const slices = computeSlices({
|
|
242
|
-
changes: symbols,
|
|
243
|
-
baseCallEdges: reconstructCallEdgesFromIR(input.baseIR),
|
|
244
|
-
headCallEdges: reconstructCallEdgesFromIR(input.headIR),
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
return {
|
|
248
|
-
$schema: DIFF_SCHEMA,
|
|
249
|
-
generator: input.generator ?? DEFAULT_GENERATOR,
|
|
250
|
-
base: input.base,
|
|
251
|
-
head: input.head,
|
|
252
|
-
summary,
|
|
253
|
-
symbols,
|
|
254
|
-
components,
|
|
255
|
-
dependencies,
|
|
256
|
-
slices,
|
|
257
|
-
notCompared: filesNeitherSideRead(lostByBase, lostByHead),
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Paths both documents record as never analysed, with each side's own reason.
|
|
263
|
-
*
|
|
264
|
-
* This is the loss `unknown` cannot describe. That status is derived from the matcher's
|
|
265
|
-
* leftovers — a Symbol one document holds and the other lacks — and a file skipped on both
|
|
266
|
-
* sides contributes Symbols to neither, so it leaves no leftover to classify and the whole
|
|
267
|
-
* document falls silent about it. Silence is the one thing it must not do here: a diff with
|
|
268
|
-
* nothing to say about a path is exactly what a diff that compared it and found it unchanged
|
|
269
|
-
* looks like.
|
|
270
|
-
*
|
|
271
|
-
* The intersection, not the union. A one-sided loss has leftovers on the other side and is
|
|
272
|
-
* reported as `unknown` there; listing it here as well would count one loss twice, in two
|
|
273
|
-
* vocabularies that mean different things.
|
|
274
|
-
*
|
|
275
|
-
* Always returns an array, empty included. Optionality in the schema covers documents written
|
|
276
|
-
* before the field existed, and no arithmetic elsewhere in a diff would let a reader tell that
|
|
277
|
-
* case from a run that missed nothing (docs/design/diff-algorithm.md §10.1).
|
|
278
|
-
*/
|
|
279
|
-
function filesNeitherSideRead(
|
|
280
|
-
lostByBase: ReadonlyMap<RelativePath, SkipReason>,
|
|
281
|
-
lostByHead: ReadonlyMap<RelativePath, SkipReason>,
|
|
282
|
-
): NotComparedFile[] {
|
|
283
|
-
const both: NotComparedFile[] = []
|
|
284
|
-
for (const [path, baseReason] of lostByBase) {
|
|
285
|
-
const headReason = lostByHead.get(path)
|
|
286
|
-
if (headReason === undefined) continue
|
|
287
|
-
both.push({ path, baseReason, headReason })
|
|
288
|
-
}
|
|
289
|
-
return both.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** §9.1 — refuse to diff across schema versions. */
|
|
293
|
-
function ensureSchemasAgree(base: IR, head: IR): void {
|
|
294
|
-
if (base.$schema !== head.$schema) {
|
|
295
|
-
throw new DiffError(
|
|
296
|
-
`Base IR schema "${base.$schema}" does not match head IR schema "${head.$schema}"; a diff across schema versions is not supported.`,
|
|
297
|
-
{ code: "schema-mismatch", value: base.$schema },
|
|
298
|
-
)
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** Which of the two inputs a message is about. */
|
|
303
|
-
type IRSide = "baseIR" | "headIR"
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* A collection `buildDiff` keys by identity. One descriptor drives both halves of the
|
|
307
|
-
* entry-point check — that the entries are objects carrying string identity fields, and that
|
|
308
|
-
* no identity repeats — so the two cannot come to describe different collections, and a
|
|
309
|
-
* fourth entry added here is guarded by both or by neither. Reporting order is the order of
|
|
310
|
-
* this array, base side before head side.
|
|
311
|
-
*/
|
|
312
|
-
interface IdentifiedCollection {
|
|
313
|
-
readonly field: "symbols" | "components" | "dependencies"
|
|
314
|
-
/** The fields identity is read from, in the order `keyOf` receives them. */
|
|
315
|
-
readonly identityFields: readonly string[]
|
|
316
|
-
/** Join them the way the diff itself keys on them, or the check guards nothing. */
|
|
317
|
-
readonly keyOf: (parts: readonly string[]) => string
|
|
318
|
-
/** How a message names the repeated value. */
|
|
319
|
-
readonly noun: string
|
|
320
|
-
/** The repeated value as the IR spells it; also what `DiffError.value` carries. */
|
|
321
|
-
readonly show: (parts: readonly string[]) => string
|
|
322
|
-
/** What the diff does with a repeat, and the invariant that forbids it. */
|
|
323
|
-
readonly consequence: string
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/** Identity is a single field, so joining the one-member tuple is joining nothing. */
|
|
327
|
-
const soleField = (parts: readonly string[]): string => parts.join("")
|
|
328
|
-
|
|
329
|
-
const IDENTIFIED_COLLECTIONS: readonly IdentifiedCollection[] = [
|
|
330
|
-
{
|
|
331
|
-
field: "symbols",
|
|
332
|
-
identityFields: ["id"],
|
|
333
|
-
keyOf: soleField,
|
|
334
|
-
noun: "id",
|
|
335
|
-
show: soleField,
|
|
336
|
-
consequence:
|
|
337
|
-
"stage 1 pairs Symbols by id and every later stage tracks the base Symbols it has " +
|
|
338
|
-
"consumed by id, so a repeat leaves one entry out of the diff entirely or classifies " +
|
|
339
|
-
"its counterpart twice (ir-schema.md §14 #1)",
|
|
340
|
-
},
|
|
341
|
-
{
|
|
342
|
-
field: "components",
|
|
343
|
-
identityFields: ["id"],
|
|
344
|
-
keyOf: soleField,
|
|
345
|
-
noun: "id",
|
|
346
|
-
show: soleField,
|
|
347
|
-
consequence:
|
|
348
|
-
"Component identity is the id, so a repeat hides one entry and can report a change " +
|
|
349
|
-
"the two revisions do not contain (ir-schema.md §14 #2)",
|
|
350
|
-
},
|
|
351
|
-
{
|
|
352
|
-
field: "dependencies",
|
|
353
|
-
identityFields: DEPENDENCY_IDENTITY_FIELDS,
|
|
354
|
-
keyOf: dependencyIdentity,
|
|
355
|
-
noun: "(from, to, via) triple",
|
|
356
|
-
show: (parts) => `(${parts.join(", ")})`,
|
|
357
|
-
consequence:
|
|
358
|
-
"direction and effect are deliberately outside Dependency identity (§6.2), so a " +
|
|
359
|
-
"repeat surfaces as an added + removed pair no reader can tell from a real flip " +
|
|
360
|
-
"(ir-schema.md §14 #13)",
|
|
361
|
-
},
|
|
362
|
-
]
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* The two things `buildDiff` needs before stage 1 runs: a Document it can walk, and
|
|
366
|
-
* identities it can key on. diff-algorithm.md §3.7 states the second and why it is checked
|
|
367
|
-
* here as well as at extraction time.
|
|
368
|
-
*
|
|
369
|
-
* Neither half is a full schema validation. A Symbol that reaches stage 1 is an object with
|
|
370
|
-
* a string `id`, because the identity scan reads that much; nothing checks it carries a
|
|
371
|
-
* `fingerprint`, which is `checkIRIntegrity` #20's job and runs when the CLI reads an IR off
|
|
372
|
-
* disk. What this does buy is that a malformed collection is named — `symbols: undefined`
|
|
373
|
-
* used to surface as `TypeError: undefined is not iterable` and `symbols: [null]` as
|
|
374
|
-
* `TypeError: Cannot read properties of null (reading 'id')`, both from inside
|
|
375
|
-
* `matchStageId`, with neither the collection nor the index named.
|
|
376
|
-
*/
|
|
377
|
-
function assertDiffable(ir: IR, name: IRSide): void {
|
|
378
|
-
if (ir === null || typeof ir !== "object") {
|
|
379
|
-
throw shapeError(name, `${name} must be an IR object; got ${describeValue(ir)}.`)
|
|
380
|
-
}
|
|
381
|
-
if (typeof ir.$schema !== "string" || ir.$schema.length === 0) {
|
|
382
|
-
throw shapeError(`${name}.$schema`, `${name}.$schema must be a non-empty schema URL.`)
|
|
383
|
-
}
|
|
384
|
-
for (const collection of IDENTIFIED_COLLECTIONS) {
|
|
385
|
-
assertUniqueIdentity(ir[collection.field], `${name}.${collection.field}`, collection)
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function assertUniqueIdentity(
|
|
390
|
-
entries: unknown,
|
|
391
|
-
subject: string,
|
|
392
|
-
collection: IdentifiedCollection,
|
|
393
|
-
): void {
|
|
394
|
-
if (!Array.isArray(entries)) {
|
|
395
|
-
throw shapeError(subject, `${subject} must be an array.`)
|
|
396
|
-
}
|
|
397
|
-
const firstSeen = new Map<string, number>()
|
|
398
|
-
for (const [index, entry] of entries.entries()) {
|
|
399
|
-
const entrySubject = `${subject}[${index}]`
|
|
400
|
-
const parts = identityFieldsOf(entry, entrySubject, collection)
|
|
401
|
-
const key = collection.keyOf(parts)
|
|
402
|
-
const first = firstSeen.get(key)
|
|
403
|
-
if (first === undefined) {
|
|
404
|
-
firstSeen.set(key, index)
|
|
405
|
-
continue
|
|
406
|
-
}
|
|
407
|
-
const shown = collection.show(parts)
|
|
408
|
-
throw new DiffError(
|
|
409
|
-
`${entrySubject} repeats the ${collection.noun} "${shown}" first seen at index ` +
|
|
410
|
-
`${first}; ${collection.consequence}.`,
|
|
411
|
-
{ code: "ir-identity-collision", value: shown },
|
|
412
|
-
)
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* The identity fields of one entry, established as strings on the way out. Reading them is
|
|
418
|
-
* what forces the check: without it a Symbol carrying no `id` has nothing to collide with,
|
|
419
|
-
* passes, and derives a Slice anchored on `undefined` several stages later — which
|
|
420
|
-
* `assertSliceRecordInvariant` reports as `slice-invariant-violated`, the one code the CLI
|
|
421
|
-
* presents as a bug in Aburi rather than in the caller's IR.
|
|
422
|
-
*/
|
|
423
|
-
function identityFieldsOf(
|
|
424
|
-
entry: unknown,
|
|
425
|
-
subject: string,
|
|
426
|
-
collection: IdentifiedCollection,
|
|
427
|
-
): string[] {
|
|
428
|
-
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
429
|
-
throw shapeError(subject, `${subject} must be an object; got ${describeValue(entry)}.`)
|
|
430
|
-
}
|
|
431
|
-
const parts: string[] = []
|
|
432
|
-
for (const field of collection.identityFields) {
|
|
433
|
-
const value = (entry as Record<string, unknown>)[field]
|
|
434
|
-
if (typeof value !== "string") {
|
|
435
|
-
throw shapeError(
|
|
436
|
-
`${subject}.${field}`,
|
|
437
|
-
`${subject}.${field} must be a string; got ${describeValue(value)}.`,
|
|
438
|
-
)
|
|
439
|
-
}
|
|
440
|
-
parts.push(value)
|
|
441
|
-
}
|
|
442
|
-
return parts
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function shapeError(subject: string, message: string): DiffError {
|
|
446
|
-
return new DiffError(message, { code: "ir-shape-invalid", value: subject })
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function describeValue(value: unknown): string {
|
|
450
|
-
if (value === undefined) return "undefined"
|
|
451
|
-
if (value === null) return "null"
|
|
452
|
-
if (Array.isArray(value)) return "an array"
|
|
453
|
-
return `a ${typeof value}`
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* Deterministic ordering of the `symbols[]` output:
|
|
458
|
-
* 1. by status (alphabetical) — pins added/changed/moved sections
|
|
459
|
-
* 2. by the "reference" id (`after` for change/move/toggle, otherwise `symbol`)
|
|
460
|
-
*
|
|
461
|
-
* The result is stable byte-for-byte for equal inputs, matching the canonicalisation
|
|
462
|
-
* guarantee the diff JSON must uphold when persisted to `out/diff.json`.
|
|
463
|
-
*/
|
|
464
|
-
function compareSymbolChange(a: SymbolChange, b: SymbolChange): number {
|
|
465
|
-
if (a.status !== b.status) return a.status < b.status ? -1 : 1
|
|
466
|
-
const idA = referenceId(a)
|
|
467
|
-
const idB = referenceId(b)
|
|
468
|
-
return idA < idB ? -1 : idA > idB ? 1 : 0
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function referenceId(change: SymbolChange): string {
|
|
472
|
-
if (change.status === "added" || change.status === "removed" || change.status === "unknown") {
|
|
473
|
-
return change.symbol.id
|
|
474
|
-
}
|
|
475
|
-
return change.after.id
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* The files a document's scan never analysed, by path, with why.
|
|
480
|
-
*
|
|
481
|
-
* `stats.skippedFiles` is Class B: absent means the writer predates the field, not that
|
|
482
|
-
* nothing was lost. An empty map is the honest answer either way — with no enumeration
|
|
483
|
-
* there is no absence this function can explain, and guessing from
|
|
484
|
-
* `totalFiles > parsedFiles` would attach a reason to whichever Symbols happened to be
|
|
485
|
-
* missing. The CLI says so on stderr instead; a diff cannot invent the list.
|
|
486
|
-
*/
|
|
487
|
-
/**
|
|
488
|
-
* Byte-deterministic serialiser for a DiffResult. Delegates to `@aburi/core`
|
|
489
|
-
* `serializeCanonical` so the sort order, NFC normalisation, and codepoint key sort are
|
|
490
|
-
* shared with the IR side.
|
|
491
|
-
*/
|
|
492
|
-
export function writeCanonicalDiff(diff: DiffResult, options: SerializeOptions = {}): string {
|
|
493
|
-
return serializeCanonical(diff, options)
|
|
494
|
-
}
|
package/src/errors.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Coded error class for every @aburi/diff failure. Consumers branch on `code` without
|
|
3
|
-
* parsing message text. Codes are stable and additions are non-breaking; renames are not.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export type DiffErrorCode =
|
|
7
|
-
/** `base.$schema` and `head.$schema` disagree; a diff across schema versions cannot be trusted (§9.1). */
|
|
8
|
-
| "schema-mismatch"
|
|
9
|
-
/** `config.diff.lineFuzz` was outside the documented [0, 10] range (diff-algorithm.md §5.2.1). */
|
|
10
|
-
| "invalid-line-fuzz"
|
|
11
|
-
/**
|
|
12
|
-
* `baseIR` or `headIR` failed the top-level shape check that `buildDiff` enforces
|
|
13
|
-
* before touching any Symbol. Missing / non-array `symbols`, `components`,
|
|
14
|
-
* `dependencies`, or an absent `$schema` — anything that would otherwise crash with a
|
|
15
|
-
* cryptic `TypeError: undefined is not iterable` deep in a matching stage.
|
|
16
|
-
*/
|
|
17
|
-
| "ir-shape-invalid"
|
|
18
|
-
/**
|
|
19
|
-
* `baseIR` or `headIR` repeats an identity the diff keys on: a `symbols[].id`, a
|
|
20
|
-
* `components[].id`, or a `dependencies[]` `(from, to, via)` triple. Distinct from
|
|
21
|
-
* `ir-shape-invalid` because the Document is well-formed — the collision changes the
|
|
22
|
-
* answer rather than preventing one, and ir-schema.md §14 #1 / #2 / #13 already forbid it.
|
|
23
|
-
*/
|
|
24
|
-
| "ir-identity-collision"
|
|
25
|
-
/**
|
|
26
|
-
* A `SliceRecord` broke the derivation invariant of slice-view.md §7.1 / §8.2:
|
|
27
|
-
* `members[]` empty, `members[]` not in strictly ascending order, or `id` not
|
|
28
|
-
* equal to `"slice:" + members[0]`. The last two compare one property against
|
|
29
|
-
* another and so have no JSON Schema equivalent; the pass checks them itself
|
|
30
|
-
* (§7.4). Raised only by an Aburi bug, never by user input — see
|
|
31
|
-
* `SliceRecordViolation.kind` for which clause broke.
|
|
32
|
-
*/
|
|
33
|
-
| "slice-invariant-violated"
|
|
34
|
-
|
|
35
|
-
export interface DiffErrorDetail {
|
|
36
|
-
code: DiffErrorCode
|
|
37
|
-
/** Offending value (schema id, fuzz value, etc.) when applicable. */
|
|
38
|
-
value?: string
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export class DiffError extends Error {
|
|
42
|
-
readonly code: DiffErrorCode
|
|
43
|
-
readonly value: string | undefined
|
|
44
|
-
|
|
45
|
-
constructor(message: string, detail: DiffErrorDetail, options?: { cause?: unknown }) {
|
|
46
|
-
super(message, options)
|
|
47
|
-
this.name = "DiffError"
|
|
48
|
-
this.code = detail.code
|
|
49
|
-
this.value = detail.value
|
|
50
|
-
}
|
|
51
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
export {
|
|
2
|
-
type DependencySideView,
|
|
3
|
-
dependencySideView,
|
|
4
|
-
diffComponents,
|
|
5
|
-
diffDependencies,
|
|
6
|
-
} from "./components"
|
|
7
|
-
export {
|
|
8
|
-
computeSymbolDelta,
|
|
9
|
-
DEFAULT_LINE_FUZZ,
|
|
10
|
-
type DeltaOptions,
|
|
11
|
-
MAX_LINE_FUZZ,
|
|
12
|
-
MIN_LINE_FUZZ,
|
|
13
|
-
} from "./delta"
|
|
14
|
-
export {
|
|
15
|
-
buildDiff,
|
|
16
|
-
type DiffInput,
|
|
17
|
-
writeCanonicalDiff,
|
|
18
|
-
} from "./diff"
|
|
19
|
-
export {
|
|
20
|
-
DiffError,
|
|
21
|
-
type DiffErrorCode,
|
|
22
|
-
type DiffErrorDetail,
|
|
23
|
-
} from "./errors"
|
|
24
|
-
export {
|
|
25
|
-
type GitRenameMap,
|
|
26
|
-
matchStageDroppedWeak,
|
|
27
|
-
matchStageGitRename,
|
|
28
|
-
matchStageId,
|
|
29
|
-
matchStageLogicFingerprint,
|
|
30
|
-
matchStageNameSignature,
|
|
31
|
-
type SymbolPair,
|
|
32
|
-
} from "./match"
|
|
33
|
-
export { signatureSimilarity } from "./signature"
|
|
34
|
-
export {
|
|
35
|
-
jaccard,
|
|
36
|
-
jaccardTokens,
|
|
37
|
-
lastSegment,
|
|
38
|
-
memberSimilarity,
|
|
39
|
-
nameSimilarity,
|
|
40
|
-
ownersAreCompatible,
|
|
41
|
-
tokenizeName,
|
|
42
|
-
} from "./similarity"
|
|
43
|
-
export {
|
|
44
|
-
assertSliceRecordInvariant,
|
|
45
|
-
computeSlices,
|
|
46
|
-
type SliceInput,
|
|
47
|
-
type SliceRecordViolation,
|
|
48
|
-
type SliceViolationKind,
|
|
49
|
-
sliceAnchor,
|
|
50
|
-
sliceRecordViolation,
|
|
51
|
-
} from "./slice"
|
|
52
|
-
export {
|
|
53
|
-
classifyStatus,
|
|
54
|
-
type DropDirection,
|
|
55
|
-
dropDirection,
|
|
56
|
-
type SymbolStatus,
|
|
57
|
-
} from "./status"
|