@aburi/diff 0.2.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/LICENSE +202 -21
- package/README.md +2 -2
- package/dist/index.d.mts +244 -52
- package/dist/index.mjs +1 -1280
- package/package.json +5 -6
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/src/components.ts +0 -111
- package/src/delta.ts +0 -304
- package/src/diff.ts +0 -250
- package/src/errors.ts +0 -44
- package/src/index.ts +0 -54
- package/src/match.ts +0 -351
- package/src/signature.ts +0 -53
- package/src/similarity.ts +0 -134
- package/src/slice.ts +0 -321
- package/src/status.ts +0 -36
package/src/signature.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { Signature } from "@aburi/types"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* §3.4.2 signatureSimilarity — averaged type-similarity across `inputs`, `outputs`, and
|
|
5
|
-
* `throws`. Follows diff-algorithm.md §3.4.2:
|
|
6
|
-
* - null + null → 1.0 (both symbols are signature-less; nothing to compare)
|
|
7
|
-
* - null + non-null (either side) → 0.0
|
|
8
|
-
* - both non-null: mean of three subscores
|
|
9
|
-
*
|
|
10
|
-
* Subscores:
|
|
11
|
-
* - inputs: ordered type-string equality rate (`i-th type equal` counts as 1, else 0)
|
|
12
|
-
* - outputs: same shape, but outputs is an array of type strings
|
|
13
|
-
* - throws: unordered set intersection over union
|
|
14
|
-
*
|
|
15
|
-
* Empty subarrays on both sides count as 1.0 (they are trivially equal); on one side only
|
|
16
|
-
* they count as 0.0 (asymmetric information).
|
|
17
|
-
*/
|
|
18
|
-
export function signatureSimilarity(
|
|
19
|
-
base: Signature | null | undefined,
|
|
20
|
-
head: Signature | null | undefined,
|
|
21
|
-
): number {
|
|
22
|
-
const baseSig = base ?? null
|
|
23
|
-
const headSig = head ?? null
|
|
24
|
-
if (baseSig === null && headSig === null) return 1
|
|
25
|
-
if (baseSig === null || headSig === null) return 0
|
|
26
|
-
const inputsScore = compareOrderedTypes(
|
|
27
|
-
baseSig.inputs.map((i) => i.type),
|
|
28
|
-
headSig.inputs.map((i) => i.type),
|
|
29
|
-
)
|
|
30
|
-
const outputsScore = compareOrderedTypes(baseSig.outputs, headSig.outputs)
|
|
31
|
-
const throwsScore = compareUnorderedSet(baseSig.throws, headSig.throws)
|
|
32
|
-
return (inputsScore + outputsScore + throwsScore) / 3
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function compareOrderedTypes(a: readonly string[], b: readonly string[]): number {
|
|
36
|
-
if (a.length === 0 && b.length === 0) return 1
|
|
37
|
-
if (a.length !== b.length) return 0
|
|
38
|
-
let matches = 0
|
|
39
|
-
for (let i = 0; i < a.length; i++) {
|
|
40
|
-
if (a[i] === b[i]) matches++
|
|
41
|
-
}
|
|
42
|
-
return matches / a.length
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function compareUnorderedSet(a: readonly string[], b: readonly string[]): number {
|
|
46
|
-
if (a.length === 0 && b.length === 0) return 1
|
|
47
|
-
const setA = new Set(a)
|
|
48
|
-
const setB = new Set(b)
|
|
49
|
-
let intersection = 0
|
|
50
|
-
for (const t of setA) if (setB.has(t)) intersection++
|
|
51
|
-
const union = setA.size + setB.size - intersection
|
|
52
|
-
return union === 0 ? 1 : intersection / union
|
|
53
|
-
}
|
package/src/similarity.ts
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* String similarity utilities dedicated to the diff engine. Kept in one file so the
|
|
3
|
-
* name/signature/owner formulas are auditable side by side against diff-algorithm.md §3.4.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Tokenise a qualified name into normalised lowercase word segments. Splits on:
|
|
8
|
-
* - camelCase / PascalCase boundaries (`fooBar` → `foo`, `Bar`)
|
|
9
|
-
* - snake_case underscores (`foo_bar` → `foo`, `bar`)
|
|
10
|
-
* - dotted namespaces (`ns.Class` → `ns`, `Class`)
|
|
11
|
-
* - `::` static-method separator (`Class::method` → `Class`, `method`)
|
|
12
|
-
* - runs of digits (`get2Users` → `get`, `2`, `Users`)
|
|
13
|
-
*
|
|
14
|
-
* The result is de-duplicated so `InvoiceService.createInvoice` collapses to
|
|
15
|
-
* `["invoice", "service", "create"]` — Jaccard similarity relies on set semantics, so
|
|
16
|
-
* repeated tokens must not double-count.
|
|
17
|
-
*/
|
|
18
|
-
export function tokenizeName(input: string): string[] {
|
|
19
|
-
const seen = new Set<string>()
|
|
20
|
-
const out: string[] = []
|
|
21
|
-
for (const raw of input.split(/[.:_\-\s/]+/)) {
|
|
22
|
-
if (raw.length === 0) continue
|
|
23
|
-
for (const piece of splitCamel(raw)) {
|
|
24
|
-
const norm = piece.toLowerCase()
|
|
25
|
-
if (norm.length === 0 || seen.has(norm)) continue
|
|
26
|
-
seen.add(norm)
|
|
27
|
-
out.push(norm)
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return out
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function splitCamel(word: string): string[] {
|
|
34
|
-
const chunks: string[] = []
|
|
35
|
-
let current = ""
|
|
36
|
-
for (const ch of word) {
|
|
37
|
-
if (current === "") {
|
|
38
|
-
current = ch
|
|
39
|
-
continue
|
|
40
|
-
}
|
|
41
|
-
const prev = current.slice(-1)
|
|
42
|
-
const boundary = isCamelBoundary(prev, ch)
|
|
43
|
-
if (boundary) {
|
|
44
|
-
chunks.push(current)
|
|
45
|
-
current = ch
|
|
46
|
-
} else {
|
|
47
|
-
current += ch
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
if (current.length > 0) chunks.push(current)
|
|
51
|
-
return chunks
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function isCamelBoundary(prev: string, curr: string): boolean {
|
|
55
|
-
const prevLower = prev >= "a" && prev <= "z"
|
|
56
|
-
const prevUpper = prev >= "A" && prev <= "Z"
|
|
57
|
-
const prevDigit = prev >= "0" && prev <= "9"
|
|
58
|
-
const currLower = curr >= "a" && curr <= "z"
|
|
59
|
-
const currUpper = curr >= "A" && curr <= "Z"
|
|
60
|
-
const currDigit = curr >= "0" && curr <= "9"
|
|
61
|
-
if (prevLower && currUpper) return true
|
|
62
|
-
if ((prevLower || prevUpper) && currDigit) return true
|
|
63
|
-
if (prevDigit && (currLower || currUpper)) return true
|
|
64
|
-
return false
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Jaccard similarity between two token multisets. Formula: |A ∩ B| / |A ∪ B|.
|
|
69
|
-
* Empty on both sides returns 1.0 (both are "no tokens", which are equivalent for the
|
|
70
|
-
* purpose of §3.4.1). Empty on one side only returns 0.0.
|
|
71
|
-
*/
|
|
72
|
-
export function jaccard(a: readonly string[], b: readonly string[]): number {
|
|
73
|
-
if (a.length === 0 && b.length === 0) return 1
|
|
74
|
-
if (a.length === 0 || b.length === 0) return 0
|
|
75
|
-
const setA = new Set(a)
|
|
76
|
-
const setB = new Set(b)
|
|
77
|
-
let intersection = 0
|
|
78
|
-
for (const t of setA) if (setB.has(t)) intersection++
|
|
79
|
-
const union = setA.size + setB.size - intersection
|
|
80
|
-
return intersection / union
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** name-token Jaccard shortcut used by nameSimilarity and ownerSimilarity call sites. */
|
|
84
|
-
export function jaccardTokens(a: string, b: string): number {
|
|
85
|
-
return jaccard(tokenizeName(a), tokenizeName(b))
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* §3.4.1 — Jaccard over the tokens of the full qualified name. The last segment is not
|
|
90
|
-
* given extra weight; the entire path contributes uniformly because the intersection is
|
|
91
|
-
* what the score should reward.
|
|
92
|
-
*/
|
|
93
|
-
export function nameSimilarity(baseName: string, headName: string): number {
|
|
94
|
-
return jaccardTokens(baseName, headName)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* §3.4.6 — owner similarity: same as nameSimilarity but restricted to everything **before**
|
|
99
|
-
* the last segment. Compensates for the R-8 same-name-different-class collision, where
|
|
100
|
-
* `UserRepo.getUser` should not be pair-swapped with `AdminRepo.getUser`.
|
|
101
|
-
*
|
|
102
|
-
* Owner extraction:
|
|
103
|
-
* - `Class::method` → `Class`
|
|
104
|
-
* - `A.B.C.method` → `A.B.C`
|
|
105
|
-
* - `topLevel` → `` (empty)
|
|
106
|
-
*
|
|
107
|
-
* When both owners are empty (top-level functions), returns 1.0 because both live in the
|
|
108
|
-
* same "no-owner" bucket. When one is empty and the other is not, returns 0.0 because the
|
|
109
|
-
* two Symbols live in structurally different scopes.
|
|
110
|
-
*/
|
|
111
|
-
export function ownerSimilarity(baseName: string, headName: string): number {
|
|
112
|
-
const baseOwner = extractOwner(baseName)
|
|
113
|
-
const headOwner = extractOwner(headName)
|
|
114
|
-
if (baseOwner === "" && headOwner === "") return 1
|
|
115
|
-
if (baseOwner === "" || headOwner === "") return 0
|
|
116
|
-
return jaccardTokens(baseOwner, headOwner)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function extractOwner(qname: string): string {
|
|
120
|
-
const staticIdx = qname.indexOf("::")
|
|
121
|
-
if (staticIdx >= 0) return qname.slice(0, staticIdx)
|
|
122
|
-
const lastDot = qname.lastIndexOf(".")
|
|
123
|
-
if (lastDot >= 0) return qname.slice(0, lastDot)
|
|
124
|
-
return ""
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** §3.4.1 tail note — used by drop-4.5 weak matcher and threshold lookup. */
|
|
128
|
-
export function lastSegment(qname: string): string {
|
|
129
|
-
const staticIdx = qname.indexOf("::")
|
|
130
|
-
if (staticIdx >= 0) return qname.slice(staticIdx + 2)
|
|
131
|
-
const lastDot = qname.lastIndexOf(".")
|
|
132
|
-
if (lastDot >= 0) return qname.slice(lastDot + 1)
|
|
133
|
-
return qname
|
|
134
|
-
}
|
package/src/slice.ts
DELETED
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
import { type CallEdge, computeWeaklyConnectedComponents, RESERVED_LANGUAGE_IDS } from "@aburi/core"
|
|
2
|
-
import type { SliceId, SliceRecord, SymbolChange, SymbolId } from "@aburi/types"
|
|
3
|
-
import { DiffError } from "./errors"
|
|
4
|
-
|
|
5
|
-
/** The three inputs of the Slice View pass — docs/design/slice-view.md §3. */
|
|
6
|
-
export interface SliceInput {
|
|
7
|
-
/** SymbolChange records produced by `buildDiff` (pre-sort is not required). */
|
|
8
|
-
changes: readonly SymbolChange[]
|
|
9
|
-
/**
|
|
10
|
-
* Resolved call edges from the base IR. Typically produced by
|
|
11
|
-
* `reconstructCallEdgesFromIR(baseIR)` inside `buildDiff` — Slice View
|
|
12
|
-
* consumes only resolved edges (§5.4), never `Symbol.calls[]` directly.
|
|
13
|
-
*/
|
|
14
|
-
baseCallEdges: readonly CallEdge[]
|
|
15
|
-
/** Resolved call edges from the head IR (same source rule as base). */
|
|
16
|
-
headCallEdges: readonly CallEdge[]
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Slice View clustering pass — docs/design/slice-view.md §2, §4–§8.
|
|
21
|
-
*
|
|
22
|
-
* Groups the changed-Symbol set of a diff into weakly-connected components
|
|
23
|
-
* over the union of base and head call edges. Emits a `SliceRecord[]` whose
|
|
24
|
-
* cluster ids are `"slice:" + <smallest-member Symbol id>`, both `slices[]`
|
|
25
|
-
* and each `members[]` sorted ascending.
|
|
26
|
-
*
|
|
27
|
-
* Pure function: `(changes, baseCallEdges, headCallEdges)` → `SliceRecord[]`.
|
|
28
|
-
* Determinism, idempotence, input-order insensitivity, and locality are all
|
|
29
|
-
* guaranteed — see `computeWeaklyConnectedComponents` in `@aburi/core`.
|
|
30
|
-
*
|
|
31
|
-
* Returns `[]` when no SymbolChange is Node-eligible per §4.1; callers should
|
|
32
|
-
* still serialise the empty array — the Markdown projection omits the section
|
|
33
|
-
* (§9.4 / §12.5) but the JSON always emits the key so consumers never
|
|
34
|
-
* distinguish "field absent" from "no slices" (§11.2).
|
|
35
|
-
*
|
|
36
|
-
* Every returned record has passed `assertSliceRecordInvariant` (§7.4).
|
|
37
|
-
*/
|
|
38
|
-
export function computeSlices(input: SliceInput): SliceRecord[] {
|
|
39
|
-
const nodeIds = collectNodeIds(input.changes)
|
|
40
|
-
if (nodeIds.length === 0) return []
|
|
41
|
-
|
|
42
|
-
const nodeIdSet = new Set<SymbolId>(nodeIds)
|
|
43
|
-
const edges = collectEdges(input.baseCallEdges, input.headCallEdges, nodeIdSet)
|
|
44
|
-
|
|
45
|
-
const components = computeWeaklyConnectedComponents<SymbolId>(nodeIds, edges, (id) => id)
|
|
46
|
-
|
|
47
|
-
return components.map(makeSliceRecord)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const SLICE_ID_PREFIX = "slice:"
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* §7.1 — the single place production code derives a Slice id. Everything else
|
|
54
|
-
* in `src/` either receives an id or renders one. (Tests spell the prefix out
|
|
55
|
-
* literally on purpose: an expectation written in terms of the function under
|
|
56
|
-
* test would agree with it no matter what it produced.)
|
|
57
|
-
*
|
|
58
|
-
* The cast is the one place a `SliceId` comes into existence. `SliceId` and
|
|
59
|
-
* `SymbolId` are separate brands precisely so this concatenation cannot be
|
|
60
|
-
* open-coded anywhere else: a bare `"slice:" + x` evaluates to `string`, which
|
|
61
|
-
* `SliceRecord.id` no longer accepts.
|
|
62
|
-
*/
|
|
63
|
-
function sliceIdFor(anchor: SymbolId): SliceId {
|
|
64
|
-
return `${SLICE_ID_PREFIX}${anchor}` as SliceId
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Build one SliceRecord from an ascending-sorted component and check its own
|
|
69
|
-
* post-condition before letting it out (§7.4 layer 1).
|
|
70
|
-
*
|
|
71
|
-
* The check is not defending against untrusted input — this function builds
|
|
72
|
-
* the id itself, so the derivation clause is true by construction here and
|
|
73
|
-
* only the two clauses about `members` can actually fire. Those are the point:
|
|
74
|
-
* `members[0]` is the anchor only because `computeWeaklyConnectedComponents`
|
|
75
|
-
* returns each component sorted ascending, a guarantee that lives one layer
|
|
76
|
-
* down and is invisible from this file. If it ever stops holding, the diff
|
|
77
|
-
* would otherwise keep emitting well-formed-looking SliceRecords naming the
|
|
78
|
-
* wrong anchor. Here it fails instead.
|
|
79
|
-
*/
|
|
80
|
-
function makeSliceRecord(members: readonly SymbolId[]): SliceRecord {
|
|
81
|
-
const anchor = members[0]
|
|
82
|
-
if (anchor === undefined) {
|
|
83
|
-
throw new DiffError(
|
|
84
|
-
"computeSlices: the clustering utility returned an empty component; every weakly-connected " +
|
|
85
|
-
"component contains at least the node that seeded it (slice-view.md §6).",
|
|
86
|
-
{ code: "slice-invariant-violated" },
|
|
87
|
-
)
|
|
88
|
-
}
|
|
89
|
-
const record: SliceRecord = { id: sliceIdFor(anchor), members: [...members] }
|
|
90
|
-
assertSliceRecordInvariant(record)
|
|
91
|
-
return record
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* §7.1 — the anchor of a Slice: its lexicographically smallest member.
|
|
96
|
-
*
|
|
97
|
-
* Answers from `members[0]`. `id` is derived from the anchor, so
|
|
98
|
-
* reconstructing the anchor by stripping the `"slice:"` prefix is circular at
|
|
99
|
-
* best and, for a record that broke the derivation, silently wrong: it would
|
|
100
|
-
* name a Symbol that is not in the Slice. `id` is read here only to name the
|
|
101
|
-
* offending record in the error message. Consumers that need the anchor call
|
|
102
|
-
* this; consumers that need a label keep using `id` directly.
|
|
103
|
-
*/
|
|
104
|
-
export function sliceAnchor(record: SliceRecord): SymbolId {
|
|
105
|
-
const anchor = record.members[0]
|
|
106
|
-
if (anchor === undefined) {
|
|
107
|
-
const violation = emptyMembersViolation(record.id)
|
|
108
|
-
throw new DiffError(violation.message, {
|
|
109
|
-
code: "slice-invariant-violated",
|
|
110
|
-
value: violation.subject,
|
|
111
|
-
})
|
|
112
|
-
}
|
|
113
|
-
return anchor
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Which clause of the §7.1 / §8.2 invariant a `SliceRecord` broke. */
|
|
117
|
-
export type SliceViolationKind =
|
|
118
|
-
/** Not a `SliceRecord` at all: not an object, or `id` / `members` of the wrong type. */
|
|
119
|
-
| "malformed-shape"
|
|
120
|
-
/** `members[]` has no entries, so the Slice has no anchor (§11.1). */
|
|
121
|
-
| "members-empty"
|
|
122
|
-
/** `members[]` is not in strictly ascending order, so `members[0]` need not be the smallest (§8.2, §11.1). */
|
|
123
|
-
| "members-unordered"
|
|
124
|
-
/** `id` is not `"slice:" + members[0]` (§7.1). */
|
|
125
|
-
| "id-not-derived"
|
|
126
|
-
/** The anchor is itself in a reserved id namespace, so `id` would read as a doubled prefix (§7.5). */
|
|
127
|
-
| "anchor-in-reserved-namespace"
|
|
128
|
-
|
|
129
|
-
export interface SliceRecordViolation {
|
|
130
|
-
kind: SliceViolationKind
|
|
131
|
-
/** The offending record's `id` when it has a usable one, else a short stand-in. */
|
|
132
|
-
subject: string
|
|
133
|
-
/** Human-facing explanation, suitable for an error message or a validator error. */
|
|
134
|
-
message: string
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Report which clause of the §7.1 / §8.2 invariant a value breaks, or `null`
|
|
139
|
-
* when it is a well-formed `SliceRecord`. Non-throwing counterpart of
|
|
140
|
-
* `assertSliceRecordInvariant`: the pass wants an exception, a schema
|
|
141
|
-
* validator wants a verdict it can turn into its own error.
|
|
142
|
-
*
|
|
143
|
-
* Takes `unknown` rather than `SliceRecord` deliberately. §7.4 layer 2 points
|
|
144
|
-
* this at documents written by third-party or older producers — data that has
|
|
145
|
-
* not been type-checked by definition — so anything that assumed a well-typed
|
|
146
|
-
* argument would crash on exactly the input it exists to reject.
|
|
147
|
-
*/
|
|
148
|
-
export function sliceRecordViolation(value: unknown): SliceRecordViolation | null {
|
|
149
|
-
if (typeof value !== "object" || value === null) {
|
|
150
|
-
return {
|
|
151
|
-
kind: "malformed-shape",
|
|
152
|
-
subject: "<not an object>",
|
|
153
|
-
message: `Expected a SliceRecord object; got ${value === null ? "null" : typeof value}.`,
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
const { id, members } = value as { id?: unknown; members?: unknown }
|
|
157
|
-
const subject = typeof id === "string" ? id : "<missing id>"
|
|
158
|
-
if (typeof id !== "string") {
|
|
159
|
-
return {
|
|
160
|
-
kind: "malformed-shape",
|
|
161
|
-
subject,
|
|
162
|
-
message: `SliceRecord ${subject}: id must be a string; got ${typeof id}.`,
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
if (!Array.isArray(members) || members.some((member) => typeof member !== "string")) {
|
|
166
|
-
return {
|
|
167
|
-
kind: "malformed-shape",
|
|
168
|
-
subject,
|
|
169
|
-
message: `SliceRecord ${subject}: members must be an array of strings.`,
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const anchor = members[0]
|
|
174
|
-
if (anchor === undefined) return emptyMembersViolation(subject)
|
|
175
|
-
// A Symbol id in the `slice:` namespace derives to `slice:slice:…`, which is
|
|
176
|
-
// self-consistent — it passes the derivation clause below — but names an id no reader can
|
|
177
|
-
// tell from a Slice id. `makeSymbolId` refuses to build such a Symbol id and
|
|
178
|
-
// `checkIRIntegrity` #16 rejects one read from disk, but `buildDiff` is public API and
|
|
179
|
-
// runs no integrity check, so the doubled prefix is caught here too.
|
|
180
|
-
const reservedAnchor = reservedNamespaceOf(anchor)
|
|
181
|
-
if (reservedAnchor !== null) {
|
|
182
|
-
return {
|
|
183
|
-
kind: "anchor-in-reserved-namespace",
|
|
184
|
-
subject,
|
|
185
|
-
message:
|
|
186
|
-
`SliceRecord anchor "${anchor}" uses the reserved language token "${reservedAnchor}", ` +
|
|
187
|
-
`so its Slice id would repeat the prefix (ir-schema.md §3.5, slice-view.md §7.5).`,
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
for (let i = 1; i < members.length; i++) {
|
|
191
|
-
const previous = members[i - 1] as string
|
|
192
|
-
const current = members[i] as string
|
|
193
|
-
if (previous < current) continue
|
|
194
|
-
return {
|
|
195
|
-
kind: "members-unordered",
|
|
196
|
-
subject,
|
|
197
|
-
message:
|
|
198
|
-
`SliceRecord ${subject}: members[] is not in strictly ascending order at index ${i} ` +
|
|
199
|
-
`("${current}" follows "${previous}"), so members[0] is not necessarily the anchor ` +
|
|
200
|
-
`(slice-view.md §8.2 for the order, §11.1 for uniqueness).`,
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
// The one place outside `sliceIdFor` that asserts an id brand, and the reason this
|
|
204
|
-
// function takes `unknown`: the anchor has been checked to be a string and nothing more.
|
|
205
|
-
// Whether it is a well-formed Symbol id is not this check's question — a record whose
|
|
206
|
-
// members are gibberish still has to be told apart from one whose id disagrees with them.
|
|
207
|
-
const expected = sliceIdFor(anchor as SymbolId)
|
|
208
|
-
if (id !== expected) {
|
|
209
|
-
return {
|
|
210
|
-
kind: "id-not-derived",
|
|
211
|
-
subject,
|
|
212
|
-
message:
|
|
213
|
-
`SliceRecord id "${id}" is not derived from the anchor "${anchor}"; ` +
|
|
214
|
-
`expected "${expected}" (slice-view.md §7.1).`,
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
return null
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** The reserved token an id opens with, or `null`. Shares the list `@aburi/core` enforces. */
|
|
221
|
-
function reservedNamespaceOf(id: string): string | null {
|
|
222
|
-
const colon = id.indexOf(":")
|
|
223
|
-
if (colon < 0) return null
|
|
224
|
-
const token = id.slice(0, colon)
|
|
225
|
-
return RESERVED_LANGUAGE_IDS.has(token) ? token : null
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** Single source of the empty-`members[]` wording, shared with `sliceAnchor`. */
|
|
229
|
-
function emptyMembersViolation(subject: string): SliceRecordViolation {
|
|
230
|
-
return {
|
|
231
|
-
kind: "members-empty",
|
|
232
|
-
subject,
|
|
233
|
-
message:
|
|
234
|
-
`SliceRecord ${subject}: members[] is empty, so the Slice has no anchor ` +
|
|
235
|
-
`(slice-view.md §11.1 requires at least one member).`,
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Throwing form of `sliceRecordViolation`. Raised as a coded `DiffError` so
|
|
241
|
-
* callers branch on `code` rather than parsing the message; which clause broke
|
|
242
|
-
* is `SliceRecordViolation.kind`, for callers that need to tell them apart.
|
|
243
|
-
*/
|
|
244
|
-
export function assertSliceRecordInvariant(record: SliceRecord): void {
|
|
245
|
-
const violation = sliceRecordViolation(record)
|
|
246
|
-
if (violation === null) return
|
|
247
|
-
throw new DiffError(violation.message, {
|
|
248
|
-
code: "slice-invariant-violated",
|
|
249
|
-
value: violation.subject,
|
|
250
|
-
})
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* §4.1 Node selection: keep every SymbolChange whose status is
|
|
255
|
-
* added / removed / changed / moved+changed / dropped-toggled. The identity
|
|
256
|
-
* is the head-side Symbol id where present (added / changed / moved+changed /
|
|
257
|
-
* dropped-toggled), the base-side id for removed. Pure moved and unchanged
|
|
258
|
-
* (already stripped upstream) are excluded — §4.1 / §4.3.
|
|
259
|
-
*
|
|
260
|
-
* The returned array is deliberately not deduplicated: a well-formed
|
|
261
|
-
* SymbolChange list from `buildDiff` never mentions the same Symbol id
|
|
262
|
-
* twice, and Union-Find would coalesce any accidental duplicate anyway.
|
|
263
|
-
*/
|
|
264
|
-
function collectNodeIds(changes: readonly SymbolChange[]): SymbolId[] {
|
|
265
|
-
const ids: SymbolId[] = []
|
|
266
|
-
for (const change of changes) {
|
|
267
|
-
const id = nodeIdOf(change)
|
|
268
|
-
if (id === null) continue
|
|
269
|
-
ids.push(id)
|
|
270
|
-
}
|
|
271
|
-
return ids
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function nodeIdOf(change: SymbolChange): SymbolId | null {
|
|
275
|
-
switch (change.status) {
|
|
276
|
-
case "added":
|
|
277
|
-
return change.symbol.id
|
|
278
|
-
case "removed":
|
|
279
|
-
// §4.1 — removed uses base-side id because there is no head symbol.
|
|
280
|
-
return change.symbol.id
|
|
281
|
-
case "changed":
|
|
282
|
-
case "moved+changed":
|
|
283
|
-
case "dropped-toggled":
|
|
284
|
-
return change.after.id
|
|
285
|
-
case "moved":
|
|
286
|
-
// §4.3 — pure moved is not a Node and is not used for bridging either.
|
|
287
|
-
return null
|
|
288
|
-
}
|
|
289
|
-
// Exhaustive: if SymbolChange grows a new status the compile error above
|
|
290
|
-
// fires before this fallback is reachable.
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* §5.1 Edge selection: union of base and head, restricted to edges whose
|
|
295
|
-
* BOTH endpoints are Nodes, canonicalised to `(u, v)` with `u < v` (self-
|
|
296
|
-
* loops implicitly dropped). Multi-edges collapse naturally inside the WCC
|
|
297
|
-
* primitive — no explicit dedup needed.
|
|
298
|
-
*
|
|
299
|
-
* The union is the load-bearing rule of §5.3: a controller that called an
|
|
300
|
-
* old service in base and a new service in head needs both edges to land
|
|
301
|
-
* all three Symbols in a single Slice.
|
|
302
|
-
*/
|
|
303
|
-
function collectEdges(
|
|
304
|
-
baseEdges: readonly CallEdge[],
|
|
305
|
-
headEdges: readonly CallEdge[],
|
|
306
|
-
nodeIds: ReadonlySet<SymbolId>,
|
|
307
|
-
): [SymbolId, SymbolId][] {
|
|
308
|
-
const pairs: [SymbolId, SymbolId][] = []
|
|
309
|
-
for (const edge of baseEdges) appendEdgeIfBothNodes(edge, nodeIds, pairs)
|
|
310
|
-
for (const edge of headEdges) appendEdgeIfBothNodes(edge, nodeIds, pairs)
|
|
311
|
-
return pairs
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function appendEdgeIfBothNodes(
|
|
315
|
-
edge: CallEdge,
|
|
316
|
-
nodeIds: ReadonlySet<SymbolId>,
|
|
317
|
-
out: [SymbolId, SymbolId][],
|
|
318
|
-
): void {
|
|
319
|
-
if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) return
|
|
320
|
-
out.push([edge.from, edge.to])
|
|
321
|
-
}
|
package/src/status.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { Symbol as IRSymbol } from "@aburi/types"
|
|
2
|
-
|
|
3
|
-
export type SymbolStatus = "unchanged" | "moved" | "changed" | "moved+changed" | "dropped-toggled"
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* §4 — determine which of the five statuses a matched pair falls into. `dropped-toggled`
|
|
7
|
-
* has absolute priority: whenever `dropped` differs between base and head, every other
|
|
8
|
-
* signal is ignored because the underlying fingerprint change is a consequence of the
|
|
9
|
-
* drop-rule flip, not a genuine code edit (§4.1 rationale).
|
|
10
|
-
*
|
|
11
|
-
* "pathChanged" catches both physical file relocation AND in-file rename: any Symbol id
|
|
12
|
-
* mismatch between base and head means the identifier moved, and DF9 ("method rename
|
|
13
|
-
* (same file, same logic) → moved") relies on this. Stage-1 pairs cannot trigger the id branch
|
|
14
|
-
* (they match by exact id), so this only affects stage-2+ pairings where the pair was
|
|
15
|
-
* discovered via fingerprint or name/signature similarity.
|
|
16
|
-
*/
|
|
17
|
-
export function classifyStatus(base: IRSymbol, head: IRSymbol): SymbolStatus {
|
|
18
|
-
const droppedToggled = base.dropped !== head.dropped
|
|
19
|
-
if (droppedToggled) return "dropped-toggled"
|
|
20
|
-
const pathChanged = base.source.file !== head.source.file || base.id !== head.id
|
|
21
|
-
const fingerprintChanged =
|
|
22
|
-
base.fingerprint.api !== head.fingerprint.api ||
|
|
23
|
-
base.fingerprint.logic !== head.fingerprint.logic ||
|
|
24
|
-
base.fingerprint.syntax !== head.fingerprint.syntax
|
|
25
|
-
if (pathChanged && fingerprintChanged) return "moved+changed"
|
|
26
|
-
if (pathChanged) return "moved"
|
|
27
|
-
if (fingerprintChanged) return "changed"
|
|
28
|
-
return "unchanged"
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Direction of a `dropped-toggled` transition (§7.1 SymbolDroppedToggled). */
|
|
32
|
-
export type DropDirection = "to-dropped" | "to-kept"
|
|
33
|
-
|
|
34
|
-
export function dropDirection(head: IRSymbol): DropDirection {
|
|
35
|
-
return head.dropped ? "to-dropped" : "to-kept"
|
|
36
|
-
}
|