diff --git a/laravel-lsp/src/facade_resolver.rs b/laravel-lsp/src/facade_resolver.rs index 4fcbcb2e..c97ab4a9 100644 --- a/laravel-lsp/src/facade_resolver.rs +++ b/laravel-lsp/src/facade_resolver.rs @@ -197,11 +197,6 @@ pub fn resolve_facade_fqcn( facade_aliases: &HashMap, is_namespaced: bool, ) -> Option { - // Distinguish a root-`\` receiver from a bare one BEFORE `resolve_class_name` - // strips the leading `\` and collapses the two cases. A leading `\` forces - // global resolution regardless of the file's namespace. - let is_root_qualified = receiver.starts_with('\\'); - // A receiver that resolves into `Illuminate\Support\Facades\*` IS a facade — // whether through a `use` import (`use …\Facades\Auth; Auth::…`) or written // inline fully-qualified (`\Illuminate\Support\Facades\Auth::…`). Return it as @@ -220,28 +215,69 @@ pub fn resolve_facade_fqcn( // Otherwise it's a bare / root-`\` token relying on the global alias. Match // it (case-insensitively, like the rest of the alias machinery) against our - // facade map — the leading `\` is already stripped by `resolve_class_name`, - // and `via_use` is the unresolved token when no import matched. - let token = via_use.as_str(); + // facade map. Reuse the `via_use` we just resolved rather than resolving a + // second time inside `global_alias_token` — the common bare-facade path + // (`Auth::user()` with no `use`) must resolve once (#267). + let token = global_alias_token_resolved(receiver, via_use, is_namespaced)?; + facade_aliases + .iter() + .find(|(alias, _)| alias.eq_ignore_ascii_case(&token)) + .map(|(_, fqcn)| fqcn.clone()) +} + +/// The bare global-alias token a receiver would look up in the facade alias map, +/// independent of whether that map currently holds it — `Some("Auth")` for a +/// bare/root-qualified `Auth` / `\Auth`, `None` for a `use`-imported facade, a +/// namespaced class reference, or a bare token in a namespaced file with no +/// import. +/// +/// This is the exact gate [`resolve_facade_fqcn`]'s global-alias branch applies +/// *before* consulting the map, factored out so the `alias:` reverse-index +/// attempt key ([`crate::magic_dependency_index::ALIAS_DEP_PREFIX`]) is recorded +/// resolved-or-not against the same tokens — the two must never drift, or an +/// alias retarget would ripple keys no call site recorded (#267). +pub fn global_alias_token( + receiver: &str, + aliases: &UseAliases, + is_namespaced: bool, +) -> Option { + global_alias_token_resolved( + receiver, + resolve_class_name(receiver, aliases), + is_namespaced, + ) +} + +/// [`global_alias_token`] over an ALREADY-resolved class name — the same gate, +/// factored so `resolve_facade_fqcn`'s common bare-facade path (which has just +/// computed `resolve_class_name`) resolves once rather than twice (#267). +/// `resolved` MUST be `resolve_class_name(receiver, aliases)` for `receiver`; +/// passing any other value silently changes the gate. +fn global_alias_token_resolved( + receiver: &str, + resolved: String, + is_namespaced: bool, +) -> Option { + // A leading `\` forces global resolution regardless of the file's namespace. + let is_root_qualified = receiver.starts_with('\\'); + // A receiver that resolves into the facade namespace via a `use` import is a + // direct facade reference, not a global-alias lookup — retargeting the + // `config/app.php` alias can't change it, so it holds no `alias:` attempt. + if resolved.starts_with(FACADE_NAMESPACE) { + return None; + } // A token that itself contains a namespace separator is a real class - // reference, not a facade alias (`App\Foo::bar()`), so it can't be a - // root-namespace facade alias — bail. - if token.contains('\\') { + // reference, not a facade alias (`App\Foo::bar()`). + if resolved.contains('\\') { return None; } - // PHP class-name rule: a bare (non-`\`-qualified) token in a namespaced file // with no matching `use` import resolves against the current namespace, NOT - // the global alias. Only honor the global alias for a leading-`\` token or a - // file with no namespace declaration. + // the global alias. if !is_root_qualified && is_namespaced { return None; } - - facade_aliases - .iter() - .find(|(alias, _)| alias.eq_ignore_ascii_case(token)) - .map(|(_, fqcn)| fqcn.clone()) + Some(resolved) } /// The container binding key a facade proxies — its `getFacadeAccessor()` diff --git a/laravel-lsp/src/magic_dependency_index.rs b/laravel-lsp/src/magic_dependency_index.rs index 2c228a7e..ff61ffc4 100644 --- a/laravel-lsp/src/magic_dependency_index.rs +++ b/laravel-lsp/src/magic_dependency_index.rs @@ -31,6 +31,30 @@ use std::path::{Path, PathBuf}; /// no concrete-FQCN dependency the registration diff could otherwise reach. pub const BINDING_DEP_PREFIX: &str = "binding:"; +/// Key-space prefix for a *facade-alias attempt* dependency: a site whose +/// receiver is a bare/root-qualified facade token resolved through the global +/// alias map (`Auth::check()`, `\Cache::get()`) records `alias:` — the +/// alias token lower-cased, resolved or not. The colon keeps the space disjoint +/// from FQCNs, and the analogue to `BINDING_DEP_PREFIX` is deliberate: recording +/// the stable *token* (not the resolved concrete) is what lets an alias +/// **retarget** ripple the OLD target's dependent sites on the very first save of +/// a session, when the registration baseline is still empty and the diff only +/// sees the new target added (#267). The lower-casing mirrors the +/// case-insensitive facade-alias matching in [`crate::facade_resolver`], so both +/// the call site and the registration diff agree on the key regardless of +/// source casing. +pub const ALIAS_DEP_PREFIX: &str = "alias:"; + +/// Build the `alias:` attempt key for a facade alias `token`, lower-cased +/// to match the case-insensitive facade matching in [`crate::facade_resolver`]. +/// Both the call-site recorder ([`crate::member_resolver`]) and the registration +/// diff ([`crate::salsa_impl::registration_ripple_keys`]) MUST build the key +/// through this one function, or the two would drift and an alias retarget would +/// ripple keys no call site recorded (#267). +pub fn alias_dep_key(token: &str) -> String { + format!("{ALIAS_DEP_PREFIX}{}", token.to_ascii_lowercase()) +} + #[derive(Default, Debug)] pub struct MagicDependencyIndex { /// fqcn → files that resolved a receiver against it. diff --git a/laravel-lsp/src/main.rs b/laravel-lsp/src/main.rs index c0e5d761..8dafaa60 100644 --- a/laravel-lsp/src/main.rs +++ b/laravel-lsp/src/main.rs @@ -7115,7 +7115,10 @@ impl LaravelLanguageServer { /// /// - **present, non-vendor** files are re-resolved in full /// (`refresh_file_magic`) — own contribution + per-file changed views — - /// exactly as a save would; + /// AND have their provider-body registrations snapshot/diffed + /// (`file_provider_registrations` → `registration_ripple_keys`) so a + /// body-only registration edit from a branch switch ripples to dependents + /// without a restart (#267), exactly as a save would; /// - **present, vendor** files get a surfaces-only pass (parse + hierarchy /// update via `get_patterns`, no own-contribution resolution) so a vendor /// base-class change still ripples to app subclasses, without eagerly @@ -7179,6 +7182,29 @@ impl LaravelLanguageServer { } // Present, non-vendor: full per-file refresh, same as a save. + // + // Registration snapshot/diff FIRST, mirroring the save path + // (`refresh_magic_on_save`): a provider-body registration edit that + // arrives here — a `git checkout` of a non-open provider, say — + // leaves the class-surface diff below empty just as an interactive + // edit does, so without this a body-only external edit never ripples + // to dependents until a restart or manual save (#267). `Some(content)` + // re-registers the fresh source (provider or config/app.php input) + // before `after` is read and advances the baseline; `before` is the + // actor-kept baseline. Taken before the per-file refresh so that pass + // already sees the fresh registries. Cheap for the non-provider + // majority: an untracked path yields empty defaults. + let (old_registrations, new_registrations) = self + .salsa + .file_provider_registrations(path.clone(), Some(content.clone())) + .await + .unwrap_or_default(); + changed_classes.extend(registration_ripple_keys( + &old_registrations, + &new_registrations, + &path, + )); + let views = self.refresh_file_magic(&path, &content).await; let new_surfaces = self .salsa diff --git a/laravel-lsp/src/member_resolver.rs b/laravel-lsp/src/member_resolver.rs index 267d2d56..4f3f4f0e 100644 --- a/laravel-lsp/src/member_resolver.rs +++ b/laravel-lsp/src/member_resolver.rs @@ -522,6 +522,17 @@ pub fn resolve_and_classify( } } + // A facade-alias receiver records its `alias:` attempt dependency — + // resolved-or-not, the exact analogue of the container branch above — so an + // alias RETARGET ripples the OLD target's sites on the first save of a + // session, when the empty registration baseline makes the diff see only the + // new target added (#267). Mirrored in [`resolve_recipe_and_classify`]. + if let Some(d) = deps.as_deref_mut() { + if let Some(key) = facade_alias_attempt_key(receiver, bytes, aliases) { + d.insert(key); + } + } + // Facade interception, checked first for a static-call name receiver — // `Auth::check()` — so the "resolved via facade" signal threads cleanly into // classification. A `Some` here means the concrete came through the facade @@ -2454,6 +2465,25 @@ fn container_attempt_key(receiver: Node, bytes: &[u8]) -> Option { }) } +/// The `alias:` reverse-index attempt key for a facade receiver that +/// resolves through the global alias map (`Auth::check()`, `\Cache::get()`), or +/// `None` for any other receiver shape. The token comes from +/// [`crate::facade_resolver::global_alias_token`] — the exact gate +/// `resolve_facade_fqcn` applies — so the key is recorded against the same tokens +/// the registration diff emits, and resolved-or-not (independent of whether the +/// token is a registered alias today) so a retarget reaches this site on the +/// first empty-baseline save (#267). Mirrors [`container_attempt_key`] for the +/// facade-alias kind. +fn facade_alias_attempt_key(receiver: Node, bytes: &[u8], aliases: &UseAliases) -> Option { + if !matches!(receiver.kind(), "name" | "qualified_name") { + return None; + } + let raw = receiver.utf8_text(bytes).ok()?; + let is_namespaced = file_namespace(receiver, bytes).is_some(); + crate::facade_resolver::global_alias_token(raw, aliases, is_namespaced) + .map(|token| crate::magic_dependency_index::alias_dep_key(&token)) +} + /// The shape of [`resolve_gate_closure_user`] — a variable that is a Gate /// ability closure's first parameter — without the auth-model lookup. fn is_gate_closure_user_shape(var_node: Node, bytes: &[u8]) -> bool { @@ -2556,6 +2586,22 @@ fn resolve_recipe_and_classify( } } + // Facade-alias attempt dependency (see [`resolve_and_classify`]'s mirror): + // the `alias:` key is recorded resolved-or-not so an alias retarget + // ripples this site on the first (empty-baseline) save (#267). + if let Some(d) = deps.as_deref_mut() { + if let ReceiverRecipeData::StaticName { + raw, is_namespaced, .. + } = &site.recipe + { + if let Some(token) = + crate::facade_resolver::global_alias_token(raw, aliases, *is_namespaced) + { + d.insert(crate::magic_dependency_index::alias_dep_key(&token)); + } + } + } + // Outer facade interception — a static-name receiver only. let facade_concrete = match &site.recipe { ReceiverRecipeData::StaticName { diff --git a/laravel-lsp/src/salsa_impl.rs b/laravel-lsp/src/salsa_impl.rs index cb950860..2034d3bf 100644 --- a/laravel-lsp/src/salsa_impl.rs +++ b/laravel-lsp/src/salsa_impl.rs @@ -4336,11 +4336,14 @@ pub struct ProviderRegistrationsData { /// reaches the sites that previously resolved to nothing — plus the /// concrete FQCN from both sides, which finds sites already referencing /// the target directly. -/// - **facade alias**: the target FQCN from both sides of the diff — a site -/// that resolved through an alias recorded the concrete it landed on, so -/// the old target finds the now-stale sites. (An alias site that never -/// resolved recorded nothing and converges on the next full pass; it holds -/// no stale classification either.) +/// - **facade alias**: the `alias:` attempt key +/// ([`crate::magic_dependency_index::ALIAS_DEP_PREFIX`]) from both sides of the +/// diff — every global-alias facade site (`Auth::check()`) records it +/// resolved-or-not, so an alias RETARGET reaches the OLD target's sites even on +/// the first save of a session, when the empty baseline makes the diff see only +/// the new target added (#267) — plus the target FQCN from both sides, which +/// finds any site referencing the aliased class directly (a `use`-import or +/// type-hint recording). /// - **the provider's own path**: macro classifications record the macro's /// declaration file as a dependency ([`crate::member_resolver`]), which for /// inline `::macro()` registrations is the registering provider itself. @@ -4374,7 +4377,14 @@ pub fn registration_ripple_keys( ] }, )); - keys.extend(changed(&before.aliases, &after.aliases).map(|(_, target)| target.clone())); + keys.extend( + changed(&before.aliases, &after.aliases).flat_map(|(token, target)| { + [ + crate::magic_dependency_index::alias_dep_key(token), + target.clone(), + ] + }), + ); if keys.is_empty() { return Vec::new(); } @@ -5076,6 +5086,15 @@ pub enum SalsaRequest { SnapshotMacros { reply: oneshot::Sender>>, }, + /// Snapshot the registered service-provider paths in the deterministic merge + /// order (`sorted_sp_files`): lexicographically ascending, which is what + /// makes an equal-priority collision resolve to the smallest path in BOTH + /// registries. Test-observability for the #255 Bug B guard — asserts the sort + /// directly, so a reverted (raw-`HashMap`) `sorted_sp_files` fails reliably + /// rather than by chance seed (#267). + SnapshotSortedProviderPaths { + reply: oneshot::Sender>, + }, /// One provider file's `(before, after)` registration contribution /// (macros / bindings / facade aliases), for the save path's registration /// diff (#255). `before` is the actor-kept BASELINE — the contribution as @@ -5836,6 +5855,20 @@ impl SalsaHandle { .map_err(|_| "Salsa actor dropped reply channel") } + /// Snapshot the registered service-provider paths in `sorted_sp_files` merge + /// order (lexicographically ascending). Test-observability for the #255 Bug B + /// guard — the deterministic sort both registries merge in. + pub async fn snapshot_sorted_provider_paths(&self) -> Result, &'static str> { + let (reply_tx, reply_rx) = oneshot::channel(); + self.sender + .send(SalsaRequest::SnapshotSortedProviderPaths { reply: reply_tx }) + .await + .map_err(|_| "Salsa actor disconnected")?; + reply_rx + .await + .map_err(|_| "Salsa actor dropped reply channel") + } + /// Snapshot the interface→implementors reverse map — `interface FQCN` → /// directly implementing class FQCNs — for the out-of-actor magic-member /// build. Mirrors [`Self::snapshot_bindings`]. @@ -7138,6 +7171,17 @@ impl SalsaActor { } let _ = reply.send(Arc::new(map)); } + SalsaRequest::SnapshotSortedProviderPaths { reply } => { + // Route through the real `sorted_sp_files` so a reverted sort + // surfaces here as raw-HashMap order — the whole point of the + // Bug B guard (#267). + let paths = self + .sorted_sp_files() + .into_iter() + .map(|sp_file| sp_file.path(&self.db).clone()) + .collect(); + let _ = reply.send(paths); + } SalsaRequest::FileProviderRegistrations { path, fresh_text, diff --git a/laravel-lsp/src/salsa_impl/tests.rs b/laravel-lsp/src/salsa_impl/tests.rs index ac5fcfc0..c072e7ac 100644 --- a/laravel-lsp/src/salsa_impl/tests.rs +++ b/laravel-lsp/src/salsa_impl/tests.rs @@ -3744,68 +3744,84 @@ class AppServiceProvider extends ServiceProvider { #[tokio::test] async fn equal_priority_collision_resolves_to_smallest_provider_path() { - // #255 Bug B regression: two providers at the SAME priority register the - // same macro key and the same binding key. `salsa_sp_files` is a HashMap - // (unspecified iteration order), so before the sorted merge the winner - // was whichever provider the map happened to yield first — flipping - // across LSP restarts. The documented rule: the lexicographically - // smallest provider path wins on an equal-priority collision, in BOTH - // registries (`sorted_sp_files`). + // #255 Bug B regression: providers at the SAME priority register the same + // macro key and the same binding key. `salsa_sp_files` is a HashMap + // (unspecified iteration order), so before the sorted merge the winner was + // whichever provider the map happened to yield first — flipping across LSP + // restarts. The documented rule: the lexicographically smallest provider + // path wins on an equal-priority collision, in BOTH registries + // (`sorted_sp_files`). + // + // The old 2-provider form caught a reverted (raw-HashMap) `sorted_sp_files` + // only ~50% of the time — the per-process seed decided the winner. This form + // is deterministic instead: it asserts `sorted_sp_files`' full ORDER via + // `snapshot_sorted_provider_paths`, so a reverted sort yields the raw HashMap + // order and matches the sorted order only 1/N! of the time — negligible at + // N=6 (~0.14%) — while still checking both registries' merge winners (#267). use tempfile::TempDir; let dir = TempDir::new().unwrap(); let root = dir.path().to_path_buf(); - let first = root.join("app/Providers/AaServiceProvider.php"); - let first_src = r#" 'aa'); - $this->app->singleton('svc', \App\Services\AaImpl::class); - } -} -"#; - let second = root.join("app/Providers/ZzServiceProvider.php"); - let second_src = r#" = names + .iter() + .map(|n| { + let path = root.join(format!("app/Providers/{n}ServiceProvider.php")); + let concrete = format!("App\\Services\\{n}Impl"); + let src = format!( + r#" 'zz'); - $this->app->singleton('svc', \App\Services\ZzImpl::class); - } -} -"#; +class {n}ServiceProvider extends ServiceProvider {{ + public function boot(): void {{ + Str::macro('shared', fn () => '{n}'); + $this->app->singleton('svc', \{concrete}::class); + }} +}} +"# + ); + (path, src, concrete) + }) + .collect(); let handle = SalsaActor::spawn(); handle .register_config_files(root.clone(), None, None, None) .await .unwrap(); - // Registration order is deliberately the REVERSE of the documented - // winner's, so an insertion-ordered merge would fail this test too. - handle - .register_service_provider_source(second.clone(), second_src.to_string(), 2, root.clone()) - .await - .unwrap(); - handle - .register_service_provider_source(first.clone(), first_src.to_string(), 2, root.clone()) - .await - .unwrap(); + // Register in the REVERSE of the winning order (F→A), so an insertion-ordered + // merge would pick the wrong provider and a raw-HashMap merge could. + for (path, src, _) in providers.iter().rev() { + handle + .register_service_provider_source(path.clone(), src.clone(), 2, root.clone()) + .await + .unwrap(); + } + + // Deterministic sort assertion: the paths in merge order MUST be the + // lexicographically sorted paths. A reverted `sorted_sp_files` yields raw + // HashMap order, which equals the sorted order only 1/6! of the time. + let mut expected_order: Vec = providers.iter().map(|(p, ..)| p.clone()).collect(); + expected_order.sort(); + let sorted_paths = handle.snapshot_sorted_provider_paths().await.unwrap(); + assert_eq!( + sorted_paths, expected_order, + "sorted_sp_files must merge providers in lexicographically ascending path order", + ); + + // …and both registries' winners must be the smallest path (`P0` = Aa). + let (smallest_path, _, smallest_concrete) = &providers[0]; - // The reversed registration order above is the actual nondeterminism - // guard: an insertion-ordered merge would pick Zz, and an unsorted - // HashMap merge could. (Re-querying an unmutated map can't change its - // iteration order, so repeating the assertions would prove nothing.) let macros = handle.snapshot_macros().await.unwrap(); let (decl_file, _) = macros .get(&("Illuminate\\Support\\Str".to_string(), "shared".to_string())) .expect("colliding macro key must still resolve"); assert_eq!( - decl_file, &first, + decl_file, smallest_path, "equal-priority macro collision must resolve to the lexicographically smallest provider path", ); @@ -3816,7 +3832,7 @@ class ZzServiceProvider extends ServiceProvider { .expect("colliding binding key must still resolve"); assert_eq!( svc.concrete_class.trim_start_matches('\\'), - "App\\Services\\AaImpl", + smallest_concrete.as_str(), "equal-priority binding collision must resolve to the lexicographically smallest provider path", ); } @@ -3944,6 +3960,13 @@ fn registration_ripple_keys_alias_retarget_emits_both_targets() { let keys = registration_ripple_keys(&before, &after, Path::new("/prov.php")); assert!(keys.contains(&"Illuminate\\Support\\Str".to_string())); assert!(keys.contains(&"App\\Support\\MyStr".to_string())); + // …and the stable `alias:` attempt key both sides share, which is the + // ONLY key that reaches the old target's sites on a first (empty-baseline) + // save (#267). Lower-cased to match the case-insensitive facade matching. + assert!( + keys.contains(&"alias:str".to_string()), + "an alias retarget must emit the alias: attempt key; got {keys:?}", + ); } #[tokio::test] diff --git a/laravel-lsp/src/tests/watched_files_magic.rs b/laravel-lsp/src/tests/watched_files_magic.rs index 1edd7a45..6d6ba0cb 100644 --- a/laravel-lsp/src/tests/watched_files_magic.rs +++ b/laravel-lsp/src/tests/watched_files_magic.rs @@ -77,6 +77,32 @@ async fn seed(backend: &LaravelLanguageServer, path: &Path, src: &str) { backend.refresh_file_magic(path, src).await; } +/// A singletons registry map binding `key` → `concrete`, in the shape the +/// actor's LIVE binding registry holds. The container-resolution resolver +/// (`app('key')`, facade accessors) reads this map — and +/// `register_service_provider_source` alone does NOT populate it (that's the +/// async App-rescan's job), so tests that resolve through a binding must drive +/// it explicitly. +fn singleton_registry( + key: &str, + concrete: &str, +) -> HashMap { + let mut m = HashMap::new(); + m.insert( + key.to_string(), + laravel_lsp::salsa_impl::BindingRegistrationData { + abstract_name: key.to_string(), + concrete_class: concrete.to_string(), + file_path: None, + binding_type: laravel_lsp::salsa_impl::BindingTypeData::Singleton, + source_file: None, + source_line: None, + priority: 2, + }, + ); + m +} + /// The resolved magic-member *member names* the index currently holds for /// `path` (empty if the file has no entries). async fn member_names(backend: &LaravelLanguageServer, path: &Path) -> HashSet { @@ -209,6 +235,369 @@ class Ids { ); } +/// #267 end-to-end, binding kind: a provider that retargets a container binding +/// (`singleton('svc', Aa)` → `singleton('svc', Bb)`) in its `boot()` body must +/// re-resolve dependent `app('svc')->…()` call sites on save, mirroring the +/// macro test but for the binding registration kind — proving the alias/binding +/// ripple end-to-end, not just at the pure-helper level. +#[tokio::test] +async fn provider_body_binding_retarget_converges_dependent_on_save() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let backend = backend_for(root).await; + write_file(root, "composer.json", COMPOSER); + + // v1 binds `svc` to `Aa`, which carries a `boom` macro. + let provider_v1 = r#" 'x'); + $this->app->singleton('svc', \App\Services\Aa::class); + } +} +"#; + let provider = write_file(root, "app/Providers/AppServiceProvider.php", provider_v1); + backend + .salsa + .register_config_files(root.to_path_buf(), None, None, None) + .await + .unwrap(); + backend + .salsa + .register_service_provider_source( + provider.clone(), + provider_v1.to_string(), + 2, + root.to_path_buf(), + ) + .await + .unwrap(); + // Drive the live binding registry `svc` → Aa (source registration alone + // doesn't populate it). + backend + .salsa + .register_service_provider_registry( + HashMap::new(), + HashMap::new(), + singleton_registry("svc", "App\\Services\\Aa"), + ) + .await + .unwrap(); + + // The dependent resolves its receiver through the `svc` binding. + let consumer_src = r#"boom(); } +} +"#; + let consumer = write_file(root, "app/Support/Uses.php", consumer_src); + seed(&backend, &consumer, consumer_src).await; + assert!( + member_names(&backend, &consumer).await.contains("boom"), + "seed: the consumer's macro call must classify while `svc` binds to Aa", + ); + + // Save #1 establishes the provider's registration baseline. + let uri = Url::from_file_path(&provider).unwrap(); + backend.refresh_magic_on_save(&uri, provider_v1).await; + drain_ripple(&backend).await; + + // Retarget the binding to `Bb`, which has no `boom` macro. Deliver the edit + // through the debounce first (like a real type→pause→save), then save. + let provider_v2 = r#"app->singleton('svc', \App\Services\Bb::class); + } +} +"#; + backend.execute_salsa_update(&uri, provider_v2, 2).await; + // The App rescan a provider save queues rebuilds the binding registry; drive + // that here so `svc` now resolves to Bb. + backend + .salsa + .register_service_provider_registry( + HashMap::new(), + HashMap::new(), + singleton_registry("svc", "App\\Services\\Bb"), + ) + .await + .unwrap(); + backend.refresh_magic_on_save(&uri, provider_v2).await; + drain_ripple(&backend).await; + + let after = member_names(&backend, &consumer).await; + assert!( + !after.contains("boom"), + "the dependent's stale binding-resolved classification must clear on the retarget save; got {after:?}" + ); +} + +/// #267 end-to-end, facade-alias kind, first-save edge: a `config/app.php` alias +/// retarget on the FIRST save of a session — when the registration baseline is +/// still empty, so the diff sees only the NEW target — must still re-resolve the +/// OLD target's dependent sites. This works ONLY because global-alias facade +/// sites record the `alias:` attempt key resolved-or-not (#267): the old +/// target FQCN is never in the diff, so `alias:cache` is the sole key that can +/// reach the stale site. +#[tokio::test] +async fn provider_body_alias_first_save_retarget_converges_dependent() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let backend = backend_for(root).await; + write_file(root, "composer.json", COMPOSER); + + // A provider binds the `cache` accessor to a concrete carrying a `boom` + // macro. The default `Cache` facade alias → `…\Facades\Cache` → accessor + // `cache` → this concrete, so the consumer resolves through the alias. + let provider_src = r#" 'x'); + $this->app->singleton('cache', \App\Services\CacheImpl::class); + } +} +"#; + let provider = write_file(root, "app/Providers/AppServiceProvider.php", provider_src); + backend + .salsa + .register_config_files(root.to_path_buf(), None, None, None) + .await + .unwrap(); + backend + .salsa + .register_service_provider_source( + provider.clone(), + provider_src.to_string(), + 2, + root.to_path_buf(), + ) + .await + .unwrap(); + // Drive the live binding registry: `cache` → CacheImpl, so the default + // `Cache` facade's accessor resolves to it. + backend + .salsa + .register_service_provider_registry( + HashMap::new(), + HashMap::new(), + singleton_registry("cache", "App\\Services\\CacheImpl"), + ) + .await + .unwrap(); + + // Global-namespace consumer using the BARE `Cache` token — the global-alias + // path, so it records `alias:cache` (a `use`-import would not). + let consumer_src = r#" [\n 'Cache' => App\\Facades\\Nothing::class,\n ],\n];\n"; + let config = write_file(root, "config/app.php", config_v1); + let uri = Url::from_file_path(&config).unwrap(); + backend.refresh_magic_on_save(&uri, config_v1).await; + drain_ripple(&backend).await; + + let after = member_names(&backend, &consumer).await; + assert!( + !after.contains("boom"), + "the old alias target's stale classification must clear on the first-save retarget; got {after:?}" + ); +} + +/// #267 AC2 (branch-switch vector), end-to-end through the WATCHED-FILES batch: +/// a provider-body macro rename that arrives via `did_change_watched_files` +/// (`FileChangeType::CHANGED`) for a NON-OPEN provider — a `git checkout`, say — +/// must re-resolve dependent call sites, exactly as the interactive save does. +/// This is the test that drives the registration snapshot/diff block in +/// `run_magic_batch_once` (`main.rs`) with a real service-provider fixture: a +/// body-only edit leaves the class-surface diff empty, so ONLY that block can +/// ripple it — revert the block and this fails. The first save merely primes the +/// registration baseline (the sibling save-path tests do the same, and it does +/// not open the document); the edit-under-test and its ripple run entirely +/// through the batch. +#[tokio::test] +async fn provider_body_macro_rename_ripples_dependent_via_watched_files() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let backend = backend_for(root).await; + write_file(root, "composer.json", COMPOSER); + + let provider_v1 = r#" 'x'); + } +} +"#; + let provider = write_file(root, "app/Providers/AppServiceProvider.php", provider_v1); + backend + .salsa + .register_config_files(root.to_path_buf(), None, None, None) + .await + .unwrap(); + backend + .salsa + .register_service_provider_source( + provider.clone(), + provider_v1.to_string(), + 2, + root.to_path_buf(), + ) + .await + .unwrap(); + + // The dependent call site, in ANOTHER file. + let consumer_src = r#"` attempt key +/// (#267 AC1) end-to-end through `run_magic_batch_once`: the old target FQCN is +/// never in the empty→config diff, so `alias:cache` is the sole key that can +/// reach the stale site. Revert the batch registration block and this fails. +#[tokio::test] +async fn config_alias_retarget_ripples_dependent_via_watched_files() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let backend = backend_for(root).await; + write_file(root, "composer.json", COMPOSER); + + // A provider binds the `cache` accessor to a concrete carrying a `boom` + // macro, so the default `Cache` facade alias resolves through to it. + let provider_src = r#" 'x'); + $this->app->singleton('cache', \App\Services\CacheImpl::class); + } +} +"#; + let provider = write_file(root, "app/Providers/AppServiceProvider.php", provider_src); + backend + .salsa + .register_config_files(root.to_path_buf(), None, None, None) + .await + .unwrap(); + backend + .salsa + .register_service_provider_source( + provider.clone(), + provider_src.to_string(), + 2, + root.to_path_buf(), + ) + .await + .unwrap(); + backend + .salsa + .register_service_provider_registry( + HashMap::new(), + HashMap::new(), + singleton_registry("cache", "App\\Services\\CacheImpl"), + ) + .await + .unwrap(); + + // Global-namespace consumer using the BARE `Cache` token — the global-alias + // path, so it records `alias:cache` (a `use`-import would not). + let consumer_src = r#" [\n 'Cache' => App\\Facades\\Nothing::class,\n ],\n];\n"; + let config = write_file(root, "config/app.php", config_src); + backend + .did_change_watched_files(watched(&config, FileChangeType::CHANGED)) + .await; + drain_batch(&backend).await; + + let after = member_names(&backend, &consumer).await; + assert!( + !after.contains("boom"), + "the old alias target's stale classification must clear on the watched-files retarget; got {after:?}" + ); +} + fn post_with_accessors(accessors: &[&str]) -> String { let methods: String = accessors .iter()