@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/diff.ts CHANGED
@@ -1,6 +1,21 @@
1
- import { type SerializeOptions, serializeCanonical } from "@aburi/core"
2
- import type { DiffResult, IR, IRRef, Summary, SymbolChange } from "@aburi/types"
3
- import { diffComponents, diffDependencies } from "./components"
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"
4
19
  import { computeSymbolDelta, type DeltaOptions } from "./delta"
5
20
  import { DiffError } from "./errors"
6
21
  import {
@@ -12,9 +27,23 @@ import {
12
27
  matchStageNameSignature,
13
28
  type SymbolPair,
14
29
  } from "./match"
30
+ import { computeSlices } from "./slice"
15
31
  import { classifyStatus, dropDirection } from "./status"
16
32
 
17
- const DIFF_SCHEMA = "https://aburi.dev/schema/aburi.diff.v1.json"
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
+ }
18
47
 
19
48
  export interface DiffInput {
20
49
  baseIR: IR
@@ -39,9 +68,11 @@ const DEFAULT_GENERATOR = { name: "aburi", version: "0.0.0" }
39
68
  * pipe the result through additional steps (Markdown projection, `--fail-on` gate) before
40
69
  * serialisation.
41
70
  */
42
- export function buildDiff(input: DiffInput): DiffResult {
43
- assertIRShape(input.baseIR, "baseIR")
44
- assertIRShape(input.headIR, "headIR")
71
+ export function buildDiff(
72
+ input: DiffInput,
73
+ ): DiffResult & { notCompared: NotComparedFile[]; summary: Summary & UnknownCounters } {
74
+ assertDiffable(input.baseIR, "baseIR")
75
+ assertDiffable(input.headIR, "headIR")
45
76
  ensureSchemasAgree(input.baseIR, input.headIR)
46
77
  const stage1 = matchStageId(input.baseIR.symbols, input.headIR.symbols)
47
78
  const stage2 = matchStageGitRename(
@@ -61,7 +92,12 @@ export function buildDiff(input: DiffInput): DiffResult {
61
92
  ...stage4_5.matched,
62
93
  ]
63
94
 
64
- const summary: Summary = {
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,
65
101
  added: 0,
66
102
  removed: 0,
67
103
  moved: 0,
@@ -77,6 +113,9 @@ export function buildDiff(input: DiffInput): DiffResult {
77
113
  depsAdded: 0,
78
114
  depsRemoved: 0,
79
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
80
119
 
81
120
  const symbols: SymbolChange[] = []
82
121
  for (const pair of pairs) {
@@ -129,19 +168,51 @@ export function buildDiff(input: DiffInput): DiffResult {
129
168
  const finalRemainingHead = stage4_5.remainingHead
130
169
  const finalRemainingBase = stage4_5.remainingBase
131
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
+
132
189
  for (const h of finalRemainingHead) {
133
- if (h.dropped) summary.droppedAdded++
134
- else {
135
- summary.added++
136
- symbols.push({ status: "added", symbol: h })
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
137
199
  }
200
+ summary.added++
201
+ symbols.push({ status: "added", symbol: h })
138
202
  }
139
203
  for (const b of finalRemainingBase) {
140
- if (b.dropped) summary.droppedRemoved++
141
- else {
142
- summary.removed++
143
- symbols.push({ status: "removed", symbol: b })
204
+ if (b.dropped) {
205
+ summary.droppedRemoved++
206
+ continue
144
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 })
145
216
  }
146
217
 
147
218
  const components = diffComponents(input.baseIR.components, input.headIR.components)
@@ -149,12 +220,30 @@ export function buildDiff(input: DiffInput): DiffResult {
149
220
  summary.componentsRemoved = components.removed.length
150
221
  summary.componentsChanged = components.changed.length
151
222
 
152
- const dependencies = diffDependencies(input.baseIR.dependencies, input.headIR.dependencies)
223
+ const dependencies = diffDependencies(input.baseIR.dependencies, input.headIR.dependencies, {
224
+ base: baseSide,
225
+ head: headSide,
226
+ })
153
227
  summary.depsAdded = dependencies.added.length
154
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
155
233
 
156
234
  symbols.sort(compareSymbolChange)
157
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
+
158
247
  return {
159
248
  $schema: DIFF_SCHEMA,
160
249
  generator: input.generator ?? DEFAULT_GENERATOR,
@@ -164,9 +253,42 @@ export function buildDiff(input: DiffInput): DiffResult {
164
253
  symbols,
165
254
  components,
166
255
  dependencies,
256
+ slices,
257
+ notCompared: filesNeitherSideRead(lostByBase, lostByHead),
167
258
  }
168
259
  }
169
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
+
170
292
  /** §9.1 — refuse to diff across schema versions. */
171
293
  function ensureSchemasAgree(base: IR, head: IR): void {
172
294
  if (base.$schema !== head.$schema) {
@@ -177,36 +299,160 @@ function ensureSchemasAgree(base: IR, head: IR): void {
177
299
  }
178
300
  }
179
301
 
302
+ /** Which of the two inputs a message is about. */
303
+ type IRSide = "baseIR" | "headIR"
304
+
180
305
  /**
181
- * Top-level shape check. Refuses to enter the 5-stage matcher when a caller hands in an
182
- * IR-shaped object that is missing one of the collections the matcher requires as an
183
- * array. Without this, a malformed `baseIR = { symbols: undefined, ... }` would crash
184
- * deep inside `matchStageId` with `TypeError: undefined is not iterable`, leaving the
185
- * root cause obscured. Not a full schema validation — just an entry-point smoke test.
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.
186
311
  */
187
- function assertIRShape(ir: IR, name: "baseIR" | "headIR"): void {
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 {
188
378
  if (ir === null || typeof ir !== "object") {
189
- throw new DiffError(`${name} must be an IR object; got ${typeof ir}.`, {
190
- code: "ir-shape-invalid",
191
- value: name,
192
- })
379
+ throw shapeError(name, `${name} must be an IR object; got ${describeValue(ir)}.`)
193
380
  }
194
381
  if (typeof ir.$schema !== "string" || ir.$schema.length === 0) {
195
- throw new DiffError(`${name}.$schema must be a non-empty schema URL.`, {
196
- code: "ir-shape-invalid",
197
- value: `${name}.$schema`,
198
- })
382
+ throw shapeError(`${name}.$schema`, `${name}.$schema must be a non-empty schema URL.`)
199
383
  }
200
- for (const field of ["symbols", "components", "dependencies"] as const) {
201
- if (!Array.isArray(ir[field])) {
202
- throw new DiffError(`${name}.${field} must be an array.`, {
203
- code: "ir-shape-invalid",
204
- value: `${name}.${field}`,
205
- })
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
206
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
+ )
207
413
  }
208
414
  }
209
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
+
210
456
  /**
211
457
  * Deterministic ordering of the `symbols[]` output:
212
458
  * 1. by status (alphabetical) — pins added/changed/moved sections
@@ -223,10 +469,21 @@ function compareSymbolChange(a: SymbolChange, b: SymbolChange): number {
223
469
  }
224
470
 
225
471
  function referenceId(change: SymbolChange): string {
226
- if (change.status === "added" || change.status === "removed") return change.symbol.id
472
+ if (change.status === "added" || change.status === "removed" || change.status === "unknown") {
473
+ return change.symbol.id
474
+ }
227
475
  return change.after.id
228
476
  }
229
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
+ */
230
487
  /**
231
488
  * Byte-deterministic serialiser for a DiffResult. Delegates to `@aburi/core`
232
489
  * `serializeCanonical` so the sort order, NFC normalisation, and codepoint key sort are
package/src/errors.ts CHANGED
@@ -15,6 +15,22 @@ export type DiffErrorCode =
15
15
  * cryptic `TypeError: undefined is not iterable` deep in a matching stage.
16
16
  */
17
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"
18
34
 
19
35
  export interface DiffErrorDetail {
20
36
  code: DiffErrorCode
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export {
2
+ type DependencySideView,
3
+ dependencySideView,
2
4
  diffComponents,
3
5
  diffDependencies,
4
6
  } from "./components"
@@ -33,10 +35,20 @@ export {
33
35
  jaccard,
34
36
  jaccardTokens,
35
37
  lastSegment,
38
+ memberSimilarity,
36
39
  nameSimilarity,
37
- ownerSimilarity,
40
+ ownersAreCompatible,
38
41
  tokenizeName,
39
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"
40
52
  export {
41
53
  classifyStatus,
42
54
  type DropDirection,