@@ -271,6 +271,138 @@ fn test_definition_id_stable_across_body_edits() {
271271 assert_ne ! ( range1, range2) ;
272272}
273273
274+ #[ test]
275+ fn test_definition_id_stable_across_def_id_renumber_local_path ( ) {
276+ // The function-scope local path looks up its `Definition` from the single
277+ // mint site `File::definitions`, whose identity is `(file, scope, name)`.
278+ // `def_id` is only the lookup key, never part of identity, so prepending
279+ // an unrelated binding inside the function (which renumbers x's `def_id`)
280+ // leaves x's salsa id unchanged. This is the same stability the export
281+ // path has in `test_definition_id_stable_across_body_edits`, now extended
282+ // to the local path.
283+ use biome_rowan:: TextSize ;
284+ use salsa:: plumbing:: AsId ;
285+
286+ let content1 = "f <- function() {\n x <- 1\n x\n }\n " ;
287+ let use1 = content1. find ( "\n x\n " ) . expect ( "standalone use of x" ) + 1 ;
288+
289+ let mut db = TestDb :: new ( ) ;
290+ let files = setup_workspace ( & mut db, & [ ( "w/a.R" , content1) ] ) ;
291+ let file = files[ 0 ] ;
292+
293+ let id1 = file
294+ . resolve_at ( & db, TextSize :: from ( use1 as u32 ) )
295+ . expect ( "use of x resolves to its function-scope binding" )
296+ . as_id ( ) ;
297+
298+ // Prepend an unrelated binding inside the function so x's DefinitionId
299+ // shifts 0 -> 1 within the function scope.
300+ let content2 = "f <- function() {\n w <- 0\n x <- 1\n x\n }\n " ;
301+ let use2 = content2. find ( "\n x\n " ) . expect ( "standalone use of x" ) + 1 ;
302+ file. set_contents ( & mut db) . to ( content2. to_string ( ) ) ;
303+
304+ let id2 = file
305+ . resolve_at ( & db, TextSize :: from ( use2 as u32 ) )
306+ . expect ( "use of x still resolves" )
307+ . as_id ( ) ;
308+
309+ assert_eq ! ( id1, id2) ;
310+ }
311+
312+ #[ test]
313+ fn test_definitions_mints_distinct_entities_for_same_name_redefinition ( ) {
314+ // Two file-scope `x` bindings share the `(file, scope, name)` id-fields.
315+ // The single mint site must create two distinct salsa entities rather than
316+ // collide or panic; salsa disambiguates same-id-field tracked structs by
317+ // creation order. Resolving `x` forces the mint of both (via `definitions`)
318+ // and must land on the last definition (offset 7).
319+ let mut db = TestDb :: new ( ) ;
320+ let files = setup_workspace ( & mut db, & [ ( "w/a.R" , "x <- 1\n x <- 2\n " ) ] ) ;
321+ let file = files[ 0 ] ;
322+
323+ let def = file. resolve ( & db, name ( & db, "x" ) ) . expect ( "x should resolve" ) ;
324+ assert_eq ! ( def. name( & db) . text( & db) . as_str( ) , "x" ) ;
325+ let range = def. name_range ( & db) . expect ( "local binding has a name range" ) ;
326+ assert_eq ! ( usize :: from( range. start( ) ) , 7 ) ;
327+ }
328+
329+ #[ test]
330+ fn test_position_shift_keeps_id_and_does_not_invalidate_identity_consumers ( ) {
331+ // A pure position shift (prepend a comment, no binding added or removed)
332+ // moves the binding's AstPtr but leaves `(file, scope, name)` and its
333+ // ordinal unchanged, so the salsa id is stable. A downstream query that
334+ // reads only identity therefore stays cached across the rebuild; only
335+ // consumers of `kind` (the moved AstPtr) would re-run.
336+ use salsa:: plumbing:: AsId ;
337+
338+ use crate :: Db ;
339+ use crate :: Definition ;
340+
341+ #[ salsa:: tracked]
342+ fn name_len < ' db > ( db : & ' db dyn Db , def : Definition < ' db > ) -> usize {
343+ def. name ( db) . text ( db) . len ( )
344+ }
345+
346+ let mut db = TestDb :: new ( ) ;
347+ let files = setup_workspace ( & mut db, & [ ( "w/a.R" , "x <- 1\n " ) ] ) ;
348+ let file = files[ 0 ] ;
349+
350+ let ( id1, range1) = {
351+ let def = file. resolve ( & db, name ( & db, "x" ) ) . expect ( "x resolves" ) ;
352+ let _ = name_len ( & db, def) ;
353+ ( def. as_id ( ) , def. name_range ( & db) )
354+ } ;
355+ assert_eq ! ( db. executions( "name_len" ) , 1 ) ;
356+
357+ // Pure position shift: x moves down a line, no binding added or removed.
358+ file. set_contents ( & mut db)
359+ . to ( "# comment\n x <- 1\n " . to_string ( ) ) ;
360+
361+ let ( id2, range2) = {
362+ let def = file. resolve ( & db, name ( & db, "x" ) ) . expect ( "x still resolves" ) ;
363+ let _ = name_len ( & db, def) ;
364+ ( def. as_id ( ) , def. name_range ( & db) )
365+ } ;
366+
367+ // Same entity, and the name range moved (it really was a position shift).
368+ assert_eq ! ( id1, id2) ;
369+ assert_ne ! ( range1, range2) ;
370+ // The identity-only consumer was not re-executed by the position shift.
371+ assert_eq ! ( db. executions( "name_len" ) , 1 ) ;
372+ }
373+
374+ #[ test]
375+ fn test_same_name_sibling_insertion_churns_later_definition_id ( ) {
376+ // TRACKING TEST for a known boundary, not a guarantee to preserve.
377+ //
378+ // Identity is `(file, scope, name)` plus salsa's creation-order
379+ // disambiguator among same-name siblings. Inserting *another* `x` earlier
380+ // in the scope shifts the ordinals of the later `x` definitions, so their
381+ // salsa ids churn even though their position-stability would otherwise
382+ // hold. This matches ty's `push_additional_definition` ordering. The test
383+ // exists to notice if salsa's disambiguation ever changes.
384+ use salsa:: plumbing:: AsId ;
385+
386+ let mut db = TestDb :: new ( ) ;
387+ let files = setup_workspace ( & mut db, & [ ( "w/a.R" , "x <- 1\n x <- 2\n " ) ] ) ;
388+ let file = files[ 0 ] ;
389+
390+ // `resolve` is last-wins, so it returns the final `x` (`x <- 2`), ordinal 1.
391+ let id1 = file. resolve ( & db, name ( & db, "x" ) ) . expect ( "x resolves" ) . as_id ( ) ;
392+
393+ // Insert another `x` at the top. The final `x` is still last-wins, but its
394+ // ordinal among same-name siblings shifts from 1 to 2.
395+ file. set_contents ( & mut db)
396+ . to ( "x <- 0\n x <- 1\n x <- 2\n " . to_string ( ) ) ;
397+
398+ let id2 = file
399+ . resolve ( & db, name ( & db, "x" ) )
400+ . expect ( "x still resolves" )
401+ . as_id ( ) ;
402+
403+ assert_ne ! ( id1, id2) ;
404+ }
405+
274406#[ test]
275407fn test_resolve_unbound_name_in_package_does_not_cycle ( ) {
276408 // Without exports-only sibling chase, A's `resolve` would walk into
0 commit comments