Skip to content

Commit 54de925

Browse files
committed
feat(compare): enhance props and emits handling
- Store full prop and emit objects with types instead of keys - Filter invalid prop/emit identifiers - Improve detection of added and removed props/emits - Update display logic for new structure - Extend unit tests for props/emits comparison
1 parent b6636e8 commit 54de925

File tree

4 files changed

+741
-214
lines changed

4 files changed

+741
-214
lines changed

src/cli/commands/compare/core.ts

Lines changed: 135 additions & 184 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,36 @@ export function normalizeNames(names: string[]): string[] {
2929
}
3030

3131
/**
32-
* Normalize an object by sorting its keys for stable comparison
32+
* Normalize an object by sorting its keys recursively for stable comparison
3333
* This ensures objects with the same content but different key order compare as equal
3434
*/
3535
function normalizeObject(obj: Record<string, any>): Record<string, any> {
36+
if (obj === null || typeof obj !== 'object') {
37+
return obj;
38+
}
39+
if (Array.isArray(obj)) {
40+
return obj.map(item => normalizeObject(item));
41+
}
3642
const sorted = Object.keys(obj)
3743
.sort()
3844
.reduce((acc, key) => {
39-
acc[key] = obj[key];
45+
acc[key] = normalizeObject(obj[key]);
4046
return acc;
4147
}, {} as Record<string, any>);
4248
return sorted;
4349
}
4450

51+
/**
52+
* Compare two values (primitives or objects) for equality with normalized key order
53+
*/
54+
function valuesEqual(a: any, b: any): boolean {
55+
if (a === b) return true;
56+
if (a === null || b === null) return a === b;
57+
if (typeof a !== typeof b) return false;
58+
if (typeof a !== 'object') return a === b;
59+
return JSON.stringify(normalizeObject(a)) === JSON.stringify(normalizeObject(b));
60+
}
61+
4562
/**
4663
* Compare two objects with normalized key order
4764
* Ensures objects with same content but different key order compare as equal
@@ -60,46 +77,47 @@ export function index(bundles: LogicStampBundle[], normalize = false): Map<strin
6077
for (const b of bundles) {
6178
for (const n of b.graph.nodes) {
6279
const c = n.contract;
63-
// Extract and sort props/emits keys for deterministic comparison
64-
// Object.keys() order depends on insertion order, so we sort to ensure consistency
65-
// BUG FIX: Filter out any non-prop-name strings (like stringified prop objects)
66-
// Only keep valid prop names (simple identifiers, no newlines, no braces)
67-
const allPropsKeys = Object.keys(c.interface?.props ?? {});
68-
const propsKeys = allPropsKeys
69-
.filter(key => {
70-
// Filter out stringified prop objects - they contain newlines or braces
71-
return typeof key === 'string' &&
72-
key.length > 0 &&
73-
!key.includes('\n') &&
74-
!key.includes('\r') &&
75-
!key.includes('{') &&
76-
!key.includes('}') &&
77-
/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key); // Valid identifier
78-
})
79-
.sort();
80-
81-
const allEmitsKeys = Object.keys(c.interface?.emits ?? {});
82-
const emitsKeys = allEmitsKeys
83-
.filter(key => {
84-
// Filter out stringified emit objects - they contain newlines or braces
85-
return typeof key === 'string' &&
86-
key.length > 0 &&
87-
!key.includes('\n') &&
88-
!key.includes('\r') &&
89-
!key.includes('{') &&
90-
!key.includes('}') &&
91-
/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key); // Valid identifier
92-
})
93-
.sort();
94-
80+
// Extract full props/emits objects with types for comparison
81+
// Filter out any invalid prop/emit names (like stringified objects)
82+
const rawProps = c.interface?.props ?? {};
83+
const rawEmits = c.interface?.emits ?? {};
84+
85+
// Filter and build props object with valid keys only
86+
const propsObj: Record<string, any> = {};
87+
for (const key of Object.keys(rawProps)) {
88+
if (typeof key === 'string' &&
89+
key.length > 0 &&
90+
!key.includes('\n') &&
91+
!key.includes('\r') &&
92+
!key.includes('{') &&
93+
!key.includes('}') &&
94+
/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
95+
propsObj[key] = rawProps[key];
96+
}
97+
}
98+
99+
// Filter and build emits object with valid keys only
100+
const emitsObj: Record<string, any> = {};
101+
for (const key of Object.keys(rawEmits)) {
102+
if (typeof key === 'string' &&
103+
key.length > 0 &&
104+
!key.includes('\n') &&
105+
!key.includes('\r') &&
106+
!key.includes('{') &&
107+
!key.includes('}') &&
108+
/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
109+
emitsObj[key] = rawEmits[key];
110+
}
111+
}
112+
95113
const sig: LiteSig = {
96114
semanticHash: c.semanticHash,
97115
imports: normalize ? normalizeNames(c.composition?.imports ?? []) : (c.composition?.imports ?? []),
98116
hooks: normalize ? normalizeNames(c.composition?.hooks ?? []) : (c.composition?.hooks ?? []),
99117
functions: normalize ? normalizeNames(c.composition?.functions ?? []) : (c.composition?.functions ?? []),
100118
components: normalize ? normalizeNames(c.composition?.components ?? []) : (c.composition?.components ?? []),
101-
props: normalize ? normalizeNames(propsKeys) : propsKeys,
102-
emits: normalize ? normalizeNames(emitsKeys) : emitsKeys,
119+
props: propsObj,
120+
emits: emitsObj,
103121
variables: normalize ? normalizeNames(c.composition?.variables ?? []) : (c.composition?.variables ?? []),
104122
state: c.interface?.state ?? {},
105123
exportKind: typeof c.exports === 'string' ? 'default'
@@ -158,130 +176,65 @@ export function diff(oldIdx: Map<string, LiteSig>, newIdx: Map<string, LiteSig>,
158176
const b = newIdx.get(id)!;
159177
const deltas: CompareResult['changed'][number]['deltas'] = [];
160178

161-
// Ensure props and emits are arrays before comparison
162-
// CRITICAL: a.props and b.props should ALWAYS be arrays from the index function
163-
// But defensively handle the case where they might be objects
164-
let aPropsArray: string[];
165-
let bPropsArray: string[];
166-
let aEmitsArray: string[];
167-
let bEmitsArray: string[];
168-
169-
// CRITICAL: Always ensure props are arrays of prop names (strings), never objects
170-
// BUG FIX: Filter out stringified prop objects that somehow got into the array
171-
// These contain newlines/braces and are not valid prop names
172-
if (Array.isArray(a.props)) {
173-
// Filter out invalid prop names (stringified objects with newlines/braces)
174-
aPropsArray = a.props.filter((p): p is string =>
175-
typeof p === 'string' &&
176-
p.length > 0 &&
177-
!p.includes('\n') &&
178-
!p.includes('\r') &&
179-
!p.includes('{') &&
180-
!p.includes('}') &&
181-
/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(p) // Valid identifier
182-
);
183-
} else if (a.props && typeof a.props === 'object' && a.props !== null) {
184-
// If it's an object, extract keys (prop names) and filter invalid ones
185-
aPropsArray = Object.keys(a.props)
186-
.filter(key =>
187-
typeof key === 'string' &&
188-
key.length > 0 &&
189-
!key.includes('\n') &&
190-
!key.includes('\r') &&
191-
!key.includes('{') &&
192-
!key.includes('}') &&
193-
/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)
194-
)
195-
.sort();
196-
} else {
197-
aPropsArray = [];
198-
}
199-
200-
if (Array.isArray(b.props)) {
201-
// Filter out invalid prop names (stringified objects with newlines/braces)
202-
bPropsArray = b.props.filter((p): p is string =>
203-
typeof p === 'string' &&
204-
p.length > 0 &&
205-
!p.includes('\n') &&
206-
!p.includes('\r') &&
207-
!p.includes('{') &&
208-
!p.includes('}') &&
209-
/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(p) // Valid identifier
210-
);
211-
} else if (b.props && typeof b.props === 'object' && b.props !== null) {
212-
// If it's an object, extract keys (prop names) and filter invalid ones
213-
bPropsArray = Object.keys(b.props)
214-
.filter(key =>
215-
typeof key === 'string' &&
216-
key.length > 0 &&
217-
!key.includes('\n') &&
218-
!key.includes('\r') &&
219-
!key.includes('{') &&
220-
!key.includes('}') &&
221-
/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)
222-
)
223-
.sort();
224-
} else {
225-
bPropsArray = [];
226-
}
227-
228-
// CRITICAL: Ensure both are arrays of valid prop name strings
229-
if (!Array.isArray(aPropsArray)) {
230-
aPropsArray = [];
231-
}
232-
if (!Array.isArray(bPropsArray)) {
233-
bPropsArray = [];
234-
}
235-
236-
// CRITICAL: Always ensure emits are arrays, never objects
237-
// Defensively handle both arrays and objects (runtime type checking)
238-
if (Array.isArray(a.emits)) {
239-
aEmitsArray = a.emits;
240-
} else if (a.emits && typeof a.emits === 'object' && a.emits !== null && !Array.isArray(a.emits)) {
241-
// If it's an object (not array), extract keys and sort for consistency
242-
aEmitsArray = Object.keys(a.emits).sort();
243-
} else {
244-
aEmitsArray = [];
179+
// Props and emits are now Record<string, any> objects
180+
// We need to detect: added keys, removed keys, and changed types
181+
const oldProps = a.props ?? {};
182+
const newProps = b.props ?? {};
183+
const oldEmits = a.emits ?? {};
184+
const newEmits = b.emits ?? {};
185+
186+
// Compare props - detect added, removed, and type changes
187+
// Note: Type changes are only detected when ignoreHashOnly=false (non-git-baseline mode)
188+
// because prop values can differ between worktree and working tree due to TS resolution differences
189+
const propsAdded: string[] = [];
190+
const propsRemoved: string[] = [];
191+
const propsChanged: Array<{name: string; old: any; new: any}> = [];
192+
193+
for (const key of Object.keys(newProps)) {
194+
if (!(key in oldProps)) {
195+
propsAdded.push(key);
196+
} else if (!ignoreHashOnly && !valuesEqual(oldProps[key], newProps[key])) {
197+
// Only detect type changes in non-git-baseline mode
198+
propsChanged.push({ name: key, old: oldProps[key], new: newProps[key] });
199+
}
245200
}
246-
247-
if (Array.isArray(b.emits)) {
248-
bEmitsArray = b.emits;
249-
} else if (b.emits && typeof b.emits === 'object' && b.emits !== null && !Array.isArray(b.emits)) {
250-
// If it's an object (not array), extract keys and sort for consistency
251-
bEmitsArray = Object.keys(b.emits).sort();
252-
} else {
253-
bEmitsArray = [];
201+
for (const key of Object.keys(oldProps)) {
202+
if (!(key in newProps)) {
203+
propsRemoved.push(key);
204+
}
254205
}
255-
256-
// CRITICAL: Ensure both are arrays before proceeding
257-
// If somehow they're still not arrays, force convert to empty arrays
258-
if (!Array.isArray(aEmitsArray)) {
259-
aEmitsArray = [];
206+
207+
// Compare emits - detect added, removed, and type changes
208+
const emitsAdded: string[] = [];
209+
const emitsRemoved: string[] = [];
210+
const emitsChanged: Array<{name: string; old: any; new: any}> = [];
211+
212+
for (const key of Object.keys(newEmits)) {
213+
if (!(key in oldEmits)) {
214+
emitsAdded.push(key);
215+
} else if (!ignoreHashOnly && !valuesEqual(oldEmits[key], newEmits[key])) {
216+
// Only detect type changes in non-git-baseline mode
217+
emitsChanged.push({ name: key, old: oldEmits[key], new: newEmits[key] });
218+
}
260219
}
261-
if (!Array.isArray(bEmitsArray)) {
262-
bEmitsArray = [];
220+
for (const key of Object.keys(oldEmits)) {
221+
if (!(key in newEmits)) {
222+
emitsRemoved.push(key);
223+
}
263224
}
225+
226+
// Determine if props/emits have any changes
227+
const propsHaveChanges = propsAdded.length > 0 || propsRemoved.length > 0 || propsChanged.length > 0;
228+
const emitsHaveChanges = emitsAdded.length > 0 || emitsRemoved.length > 0 || emitsChanged.length > 0;
264229

265-
// Check for non-hash changes first
266-
// Note: props and emits are string arrays (prop/emit names), state and apiSignature are objects
267-
// CRITICAL: Use the extracted arrays (aPropsArray, bPropsArray) for comparison, not a.props/b.props directly
268-
// Normalize props/emits arrays for comparison to ensure consistent comparison
269-
const oldPropsNormalized = normalize ? normalizeNames(aPropsArray) : [...aPropsArray].sort();
270-
const newPropsNormalized = normalize ? normalizeNames(bPropsArray) : [...bPropsArray].sort();
271-
const propsEqual = JSON.stringify(oldPropsNormalized) === JSON.stringify(newPropsNormalized);
272-
273-
const oldEmitsNormalized = normalize ? normalizeNames(aEmitsArray) : [...aEmitsArray].sort();
274-
const newEmitsNormalized = normalize ? normalizeNames(bEmitsArray) : [...bEmitsArray].sort();
275-
const emitsEqual = JSON.stringify(oldEmitsNormalized) === JSON.stringify(newEmitsNormalized);
276-
277-
const hasNonHashChanges =
230+
const hasNonHashChanges =
278231
!arraysEqual(a.imports, b.imports, normalize) ||
279232
!arraysEqual(a.hooks, b.hooks, normalize) ||
280233
!arraysEqual(a.functions, b.functions, normalize) ||
281234
!arraysEqual(a.components, b.components, normalize) ||
282235
!arraysEqual(a.variables, b.variables, normalize) ||
283-
!propsEqual ||
284-
!emitsEqual ||
236+
propsHaveChanges ||
237+
emitsHaveChanges ||
285238
!objectsEqual(a.state, b.state) ||
286239
a.exportKind !== b.exportKind ||
287240
!objectsEqual(a.apiSignature ?? {}, b.apiSignature ?? {});
@@ -307,42 +260,40 @@ export function diff(oldIdx: Map<string, LiteSig>, newIdx: Map<string, LiteSig>,
307260
deltas.push({ type: 'components', old: a.components, new: b.components });
308261
}
309262

310-
// Only add props delta if they're actually different (reuse propsEqual computed above)
311-
if (!propsEqual) {
312-
// CRITICAL: Ensure we're ALWAYS storing arrays of strings (prop names), never objects
313-
// aPropsArray and bPropsArray are already filtered to valid prop names only
314-
const oldPropsFinal = [...aPropsArray].sort();
315-
const newPropsFinal = [...bPropsArray].sort();
316-
deltas.push({ type: 'props', old: oldPropsFinal, new: newPropsFinal });
263+
// Add props deltas for added/removed props
264+
if (propsAdded.length > 0 || propsRemoved.length > 0) {
265+
deltas.push({
266+
type: 'props',
267+
old: propsRemoved.sort(),
268+
new: propsAdded.sort()
269+
});
317270
}
318271

319-
// Only add emits delta if they're actually different (reuse emitsEqual computed above)
320-
if (!emitsEqual) {
321-
// CRITICAL: Ensure we're ALWAYS storing arrays of strings (emit names), never objects
322-
// aEmitsArray and bEmitsArray should already be arrays, but defensively ensure they are
323-
let oldEmitsFinal: string[];
324-
let newEmitsFinal: string[];
325-
326-
if (Array.isArray(aEmitsArray)) {
327-
oldEmitsFinal = [...aEmitsArray].sort();
328-
} else if (aEmitsArray && typeof aEmitsArray === 'object') {
329-
oldEmitsFinal = Object.keys(aEmitsArray).sort();
330-
} else {
331-
oldEmitsFinal = [];
332-
}
333-
334-
if (Array.isArray(bEmitsArray)) {
335-
newEmitsFinal = [...bEmitsArray].sort();
336-
} else if (bEmitsArray && typeof bEmitsArray === 'object') {
337-
newEmitsFinal = Object.keys(bEmitsArray).sort();
338-
} else {
339-
newEmitsFinal = [];
340-
}
341-
342-
// Final safety check: ensure we're storing arrays, not objects
343-
if (Array.isArray(oldEmitsFinal) && Array.isArray(newEmitsFinal)) {
344-
deltas.push({ type: 'emits', old: oldEmitsFinal, new: newEmitsFinal });
345-
}
272+
// Add propsChanged delta for type changes
273+
if (propsChanged.length > 0) {
274+
deltas.push({
275+
type: 'propsChanged',
276+
old: null,
277+
new: propsChanged.sort((x, y) => x.name.localeCompare(y.name))
278+
});
279+
}
280+
281+
// Add emits deltas for added/removed emits
282+
if (emitsAdded.length > 0 || emitsRemoved.length > 0) {
283+
deltas.push({
284+
type: 'emits',
285+
old: emitsRemoved.sort(),
286+
new: emitsAdded.sort()
287+
});
288+
}
289+
290+
// Add emitsChanged delta for type changes
291+
if (emitsChanged.length > 0) {
292+
deltas.push({
293+
type: 'emitsChanged',
294+
old: null,
295+
new: emitsChanged.sort((x, y) => x.name.localeCompare(y.name))
296+
});
346297
}
347298

348299
if (!arraysEqual(a.variables, b.variables, normalize)) {

0 commit comments

Comments
 (0)