@@ -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 */
3535function 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 - z A - Z _ $ ] [ a - z A - Z 0 - 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 - z A - Z _ $ ] [ a - z A - Z 0 - 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 - z A - Z _ $ ] [ a - z A - Z 0 - 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 - z A - Z _ $ ] [ a - z A - Z 0 - 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 - z A - Z _ $ ] [ a - z A - Z 0 - 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 - z A - Z _ $ ] [ a - z A - Z 0 - 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 - z A - Z _ $ ] [ a - z A - Z 0 - 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 - z A - Z _ $ ] [ a - z A - Z 0 - 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