Skip to main content

mago_codex/metadata/
mod.rs

1use std::borrow::Cow;
2use std::collections::hash_map::Entry;
3
4use foldhash::HashMap;
5use foldhash::HashSet;
6
7use mago_database::file::File;
8use mago_database::file::FileId;
9use mago_reporting::Annotation;
10use mago_reporting::Issue;
11use mago_reporting::IssueCollection;
12use mago_span::Span;
13use mago_word::Word;
14use mago_word::WordMap;
15use mago_word::WordSet;
16use mago_word::ascii_lowercase_constant_name_word;
17use mago_word::ascii_lowercase_word;
18use mago_word::empty_word;
19use mago_word::word;
20
21use crate::diff::CodebaseDiff;
22use crate::identifier::method::MethodIdentifier;
23use crate::issue::ScanningIssueKind;
24use crate::metadata::class_like::ClassLikeMetadata;
25use crate::metadata::class_like_constant::ClassLikeConstantMetadata;
26use crate::metadata::constant::ConstantMetadata;
27use crate::metadata::enum_case::EnumCaseMetadata;
28use crate::metadata::flags::MetadataFlags;
29use crate::metadata::function_like::FunctionLikeMetadata;
30use crate::metadata::property::PropertyMetadata;
31use crate::metadata::ttype::TypeMetadata;
32use crate::reference::SymbolReferences;
33use crate::signature::FileSignature;
34use crate::symbol::SymbolKind;
35use crate::symbol::Symbols;
36use crate::ttype::atomic::TAtomic;
37use crate::ttype::atomic::object::TObject;
38use crate::ttype::union::TUnion;
39use crate::visibility::Visibility;
40
41pub mod attribute;
42pub mod class_like;
43pub mod class_like_constant;
44pub mod constant;
45pub mod enum_case;
46pub mod flags;
47pub mod function_like;
48pub mod parameter;
49pub mod property;
50pub mod property_hook;
51pub mod ttype;
52pub mod version_constraint;
53
54/// Lightweight set of keys extracted from a per-file [`CodebaseMetadata`].
55///
56/// Used by the incremental engine to efficiently remove a file's contributions from the
57/// merged codebase without keeping a full `CodebaseMetadata` clone per file.
58/// Created via [`CodebaseMetadata::extract_keys()`].
59#[derive(Debug, Clone)]
60pub struct CodebaseEntryKeys {
61    /// Class-like FQCN atoms (also used for symbol removal).
62    pub class_like_names: Vec<Word>,
63    pub class_like_aliases: Vec<(Word, Word, Span)>,
64    /// Function-like `(scope, name)` tuples.
65    pub function_like_keys: Vec<(Word, Word)>,
66    /// Constant FQN atoms.
67    pub constant_names: Vec<Word>,
68    /// File IDs that had signatures in this metadata.
69    pub file_ids: Vec<FileId>,
70}
71
72/// Holds all analyzed information about the symbols, structures, and relationships within a codebase.
73///
74/// This acts as the central repository for metadata gathered during static analysis,
75/// including details about classes, interfaces, traits, enums, functions, constants,
76/// their members, inheritance, dependencies, and associated types.
77#[derive(Clone, Debug, PartialEq, Default)]
78#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
79#[non_exhaustive]
80#[allow(clippy::unsafe_derive_deserialize)]
81pub struct CodebaseMetadata {
82    /// Configuration flag: Should types be inferred based on usage patterns?
83    pub infer_types_from_usage: bool,
84    /// Map from class-like FQCN (`Word`) to its detailed metadata (`ClassLikeMetadata`).
85    pub class_likes: WordMap<ClassLikeMetadata>,
86    #[cfg_attr(feature = "serde", serde(default))]
87    pub class_like_aliases: WordMap<Word>,
88    #[cfg_attr(feature = "serde", serde(default))]
89    class_like_alias_declarations: WordMap<(Word, Span, MetadataFlags)>,
90    #[cfg_attr(feature = "serde", serde(skip, default))]
91    class_like_aliases_dirty: bool,
92    /// Map from a function/method identifier tuple `(scope_id, function_id)` to its metadata (`FunctionLikeMetadata`).
93    /// `scope_id` is the FQCN for methods or often `Word::empty()` for global functions.
94    pub function_likes: HashMap<(Word, Word), FunctionLikeMetadata>,
95    /// Stores the kind (Class, Interface, etc.) for every known symbol FQCN.
96    pub symbols: Symbols,
97    /// Map from global constant FQN (`Word`) to its metadata (`ConstantMetadata`).
98    pub constants: WordMap<ConstantMetadata>,
99    /// Map from class/interface FQCN to the set of all its descendants (recursive).
100    pub all_class_like_descendants: WordMap<WordSet>,
101    /// Map from class/interface FQCN to the set of its direct descendants (children).
102    pub direct_classlike_descendants: WordMap<WordSet>,
103    /// Set of symbols (FQCNs) that are considered safe/validated.
104    pub safe_symbols: WordSet,
105    /// Set of specific members `(SymbolFQCN, MemberName)` that are considered safe/validated.
106    pub safe_symbol_members: HashSet<(Word, Word)>,
107    /// Each `FileSignature` contains a hierarchical tree of `DefSignatureNode` representing
108    /// top-level symbols (classes, functions, constants) and their nested members (methods, properties).
109    pub file_signatures: HashMap<FileId, FileSignature>,
110    /// Per-patch class-like metadata, keyed by FQCN.
111    ///
112    /// Vendor and patch files declare symbols under the same FQCN, so patches cannot share
113    /// the `class_likes` map. At most one patch may target a given symbol; a second patch for
114    /// the same FQCN is diagnosed as a [`PatchDuplicateTarget`](ScanningIssueKind::PatchDuplicateTarget)
115    /// rather than silently overwriting the first. Entries here are folded into `class_likes`
116    /// by [`apply_patches_pass`](Self::apply_patches_pass).
117    pub patch_class_likes: WordMap<ClassLikeMetadata>,
118    /// Per-patch function-like metadata, keyed by `(scope, name)`.
119    ///
120    /// The key matches the existing `function_likes` key shape: the FQCN for methods,
121    /// `empty_word()` for free functions.
122    pub patch_function_likes: HashMap<(Word, Word), FunctionLikeMetadata>,
123    /// Per-patch constant metadata, keyed by FQN.
124    pub patch_constants: WordMap<ConstantMetadata>,
125}
126
127impl CodebaseMetadata {
128    /// Creates a new, empty `CodebaseMetadata` with default values.
129    #[inline]
130    #[must_use]
131    pub fn new() -> Self {
132        Self::default()
133    }
134
135    pub(crate) fn add_class_like_alias(&mut self, alias: Word, target: Word, span: Span, flags: MetadataFlags) {
136        let replace =
137            self.class_like_alias_declarations.get(&alias).is_none_or(|(_, existing_span, existing_flags)| {
138                should_replace_metadata(*existing_flags, *existing_span, flags, span)
139            });
140
141        if replace {
142            self.class_like_alias_declarations.insert(alias, (target, span, flags));
143            self.class_like_aliases_dirty = true;
144        }
145    }
146
147    #[inline]
148    pub fn class_like_alias_declarations(&self) -> impl Iterator<Item = (Word, Word, Span)> + '_ {
149        self.class_like_alias_declarations.iter().map(|(alias, (target, span, _))| (*alias, *target, *span))
150    }
151
152    pub(crate) fn populate_class_like_aliases(&mut self) -> bool {
153        if !self.class_like_aliases_dirty {
154            return false;
155        }
156
157        let previous_aliases = std::mem::take(&mut self.class_like_aliases);
158        for alias in previous_aliases.keys().copied() {
159            if let Some(metadata) = self.class_likes.get(&alias) {
160                self.symbols.add_symbol_name(alias, metadata.kind);
161            } else {
162                self.symbols.remove(alias);
163            }
164        }
165
166        for metadata in self.class_likes.values_mut() {
167            metadata.aliases.clear();
168        }
169
170        let mut aliases = WordMap::default();
171        for alias in self.class_like_alias_declarations.keys().copied() {
172            if self.class_likes.contains_key(&alias) {
173                continue;
174            }
175
176            if let Some(actual) = self.resolve_class_like_alias_declaration(alias) {
177                aliases.insert(alias, actual);
178            }
179        }
180
181        let aliases_changed = aliases != previous_aliases;
182        self.class_like_aliases = aliases;
183        let mut aliased_classes = WordSet::default();
184        for (alias, actual) in &self.class_like_aliases {
185            let Some(metadata) = self.class_likes.get_mut(actual) else {
186                continue;
187            };
188
189            metadata.aliases.push(*alias);
190            aliased_classes.insert(*actual);
191            self.symbols.add_symbol_name(*alias, metadata.kind);
192        }
193
194        for actual in aliased_classes {
195            if let Some(metadata) = self.class_likes.get_mut(&actual) {
196                metadata.aliases.sort_unstable();
197            }
198        }
199
200        self.class_like_aliases_dirty = false;
201        aliases_changed
202    }
203
204    fn resolve_class_like_alias_declaration(&self, alias: Word) -> Option<Word> {
205        let mut current = self.class_like_alias_declarations.get(&alias)?.0;
206
207        for _ in 0..self.class_like_alias_declarations.len() {
208            if self.class_likes.contains_key(&current) {
209                return Some(current);
210            }
211
212            current = self.class_like_alias_declarations.get(&current)?.0;
213        }
214
215        None
216    }
217
218    #[inline]
219    pub(crate) fn resolve_class_like_word(&self, name: Word) -> Option<Word> {
220        if self.class_likes.contains_key(&name) {
221            return Some(name);
222        }
223
224        if self.class_like_aliases.is_empty() {
225            return None;
226        }
227
228        self.class_like_aliases.get(&name).copied()
229    }
230
231    #[inline]
232    pub(crate) fn get_class_like_by_word(&self, name: Word) -> Option<&ClassLikeMetadata> {
233        if let Some(metadata) = self.class_likes.get(&name) {
234            return Some(metadata);
235        }
236
237        if self.class_like_aliases.is_empty() {
238            return None;
239        }
240
241        self.class_like_aliases.get(&name).and_then(|actual| self.class_likes.get(actual))
242    }
243
244    #[inline]
245    fn get_method_by_words(&self, class: Word, method: Word) -> Option<&FunctionLikeMetadata> {
246        if let Some(metadata) = self.function_likes.get(&(class, method)) {
247            return Some(metadata);
248        }
249
250        if self.class_like_aliases.is_empty() {
251            return None;
252        }
253
254        let actual = self.class_like_aliases.get(&class)?;
255        self.function_likes.get(&(*actual, method))
256    }
257
258    /// Checks if a class exists in the codebase (case-insensitive).
259    ///
260    /// # Examples
261    /// ```ignore
262    /// if codebase.class_exists("MyClass") {
263    ///     // MyClass is a class
264    /// }
265    /// ```
266    #[inline]
267    #[must_use]
268    pub fn class_exists(&self, name: &[u8]) -> bool {
269        let lowercase_name = ascii_lowercase_word(name);
270        matches!(self.symbols.get_kind(lowercase_name), Some(SymbolKind::Class))
271    }
272
273    /// Checks if an interface exists in the codebase (case-insensitive).
274    #[inline]
275    #[must_use]
276    pub fn interface_exists(&self, name: &[u8]) -> bool {
277        let lowercase_name = ascii_lowercase_word(name);
278        matches!(self.symbols.get_kind(lowercase_name), Some(SymbolKind::Interface))
279    }
280
281    /// Checks if a trait exists in the codebase (case-insensitive).
282    #[inline]
283    #[must_use]
284    pub fn trait_exists(&self, name: &[u8]) -> bool {
285        let lowercase_name = ascii_lowercase_word(name);
286        matches!(self.symbols.get_kind(lowercase_name), Some(SymbolKind::Trait))
287    }
288
289    /// Checks if an enum exists in the codebase (case-insensitive).
290    #[inline]
291    #[must_use]
292    pub fn enum_exists(&self, name: &[u8]) -> bool {
293        let lowercase_name = ascii_lowercase_word(name);
294        matches!(self.symbols.get_kind(lowercase_name), Some(SymbolKind::Enum))
295    }
296
297    /// Checks if a class-like (class, interface, trait, or enum) exists (case-insensitive).
298    #[inline]
299    #[must_use]
300    pub fn class_like_exists(&self, name: &[u8]) -> bool {
301        let lowercase_name = ascii_lowercase_word(name);
302        self.symbols.contains(lowercase_name)
303    }
304
305    /// Checks if a namespace exists (case-insensitive).
306    #[inline]
307    #[must_use]
308    pub fn namespace_exists(&self, name: &[u8]) -> bool {
309        let lowercase_name = ascii_lowercase_word(name);
310        self.symbols.contains_namespace(lowercase_name)
311    }
312
313    /// Checks if a class or trait exists in the codebase (case-insensitive).
314    #[inline]
315    #[must_use]
316    pub fn class_or_trait_exists(&self, name: &[u8]) -> bool {
317        let lowercase_name = ascii_lowercase_word(name);
318        matches!(self.symbols.get_kind(lowercase_name), Some(SymbolKind::Class | SymbolKind::Trait))
319    }
320
321    /// Checks if a class or interface exists in the codebase (case-insensitive).
322    #[inline]
323    #[must_use]
324    pub fn class_or_interface_exists(&self, name: &[u8]) -> bool {
325        let lowercase_name = ascii_lowercase_word(name);
326        matches!(self.symbols.get_kind(lowercase_name), Some(SymbolKind::Class | SymbolKind::Interface))
327    }
328
329    /// Checks if a method identifier exists in the codebase.
330    #[inline]
331    #[must_use]
332    pub fn method_identifier_exists(&self, method_id: &MethodIdentifier) -> bool {
333        let lowercase_class = ascii_lowercase_word(method_id.get_class_name().as_bytes());
334        let lowercase_method = ascii_lowercase_word(method_id.get_method_name().as_bytes());
335        self.get_method_by_words(lowercase_class, lowercase_method).is_some()
336    }
337
338    /// Checks if a global function exists in the codebase (case-insensitive).
339    #[inline]
340    #[must_use]
341    pub fn function_exists(&self, name: &[u8]) -> bool {
342        let lowercase_name = ascii_lowercase_word(name);
343        let identifier = (empty_word(), lowercase_name);
344        self.function_likes.contains_key(&identifier)
345    }
346
347    /// Checks if a global constant exists in the codebase.
348    /// The namespace part is case-insensitive, but the constant name is case-sensitive.
349    #[inline]
350    #[must_use]
351    pub fn constant_exists(&self, name: &[u8]) -> bool {
352        let lowercase_name = ascii_lowercase_constant_name_word(name);
353        self.constants.contains_key(&lowercase_name)
354    }
355
356    /// Checks if a method exists on a class-like, including inherited methods (case-insensitive).
357    #[inline]
358    #[must_use]
359    pub fn method_exists(&self, class: &[u8], method: &[u8]) -> bool {
360        let lowercase_class = ascii_lowercase_word(class);
361        let lowercase_method = ascii_lowercase_word(method);
362        self.get_class_like_by_word(lowercase_class)
363            .is_some_and(|meta| meta.appearing_method_ids.contains_key(&lowercase_method))
364    }
365
366    /// Checks if a property exists on a class-like, including inherited properties.
367    /// Class name is case-insensitive, property name is case-sensitive.
368    /// Sees real declarations only; magic `@property*` tags are reachable through
369    /// `ClassLikeMetadata::magic_property_ids`.
370    #[inline]
371    #[must_use]
372    pub fn property_exists(&self, class: &[u8], property: &[u8]) -> bool {
373        let lowercase_class = ascii_lowercase_word(class);
374        let property_name = word(property);
375        self.get_class_like_by_word(lowercase_class)
376            .is_some_and(|meta| meta.appearing_property_ids.contains_key(&property_name))
377    }
378
379    /// Checks if a magic `@property*` exists on a class-like, including inherited tags.
380    /// Class name is case-insensitive, property name is case-sensitive.
381    #[inline]
382    #[must_use]
383    pub fn magic_property_exists(&self, class: &[u8], property: &[u8]) -> bool {
384        let lowercase_class = ascii_lowercase_word(class);
385        let property_name = word(property);
386        self.get_class_like_by_word(lowercase_class)
387            .is_some_and(|meta| meta.magic_property_ids.contains_key(&property_name))
388    }
389
390    /// Checks if a class constant or enum case exists on a class-like.
391    /// Class name is case-insensitive, constant/case name is case-sensitive.
392    #[inline]
393    #[must_use]
394    pub fn class_constant_exists(&self, class: &[u8], constant: &[u8]) -> bool {
395        let lowercase_class = ascii_lowercase_word(class);
396        let constant_name = word(constant);
397        self.get_class_like_by_word(lowercase_class).is_some_and(|meta| {
398            meta.constants.contains_key(&constant_name) || meta.enum_cases.contains_key(&constant_name)
399        })
400    }
401
402    /// Retrieves metadata for a class (case-insensitive).
403    /// Returns `None` if the name doesn't correspond to a class.
404    #[inline]
405    #[must_use]
406    pub fn get_class(&self, name: &[u8]) -> Option<&ClassLikeMetadata> {
407        let lowercase_name = ascii_lowercase_word(name);
408        self.get_class_like_by_word(lowercase_name).filter(|metadata| metadata.kind.is_class())
409    }
410
411    /// Retrieves metadata for an interface (case-insensitive).
412    #[inline]
413    #[must_use]
414    pub fn get_interface(&self, name: &[u8]) -> Option<&ClassLikeMetadata> {
415        let lowercase_name = ascii_lowercase_word(name);
416        self.get_class_like_by_word(lowercase_name).filter(|metadata| metadata.kind.is_interface())
417    }
418
419    /// Retrieves metadata for a trait (case-insensitive).
420    #[inline]
421    #[must_use]
422    pub fn get_trait(&self, name: &[u8]) -> Option<&ClassLikeMetadata> {
423        let lowercase_name = ascii_lowercase_word(name);
424        self.get_class_like_by_word(lowercase_name).filter(|metadata| metadata.kind.is_trait())
425    }
426
427    /// Retrieves metadata for an enum (case-insensitive).
428    #[inline]
429    #[must_use]
430    pub fn get_enum(&self, name: &[u8]) -> Option<&ClassLikeMetadata> {
431        let lowercase_name = ascii_lowercase_word(name);
432        self.get_class_like_by_word(lowercase_name).filter(|metadata| metadata.kind.is_enum())
433    }
434
435    /// Retrieves metadata for any class-like structure (case-insensitive).
436    #[inline]
437    #[must_use]
438    pub fn get_class_like(&self, name: &[u8]) -> Option<&ClassLikeMetadata> {
439        let lowercase_name = ascii_lowercase_word(name);
440        self.get_class_like_by_word(lowercase_name)
441    }
442
443    /// Retrieves metadata for a global function (case-insensitive).
444    #[inline]
445    #[must_use]
446    pub fn get_function(&self, name: &[u8]) -> Option<&FunctionLikeMetadata> {
447        let lowercase_name = ascii_lowercase_word(name);
448        let identifier = (empty_word(), lowercase_name);
449        self.function_likes.get(&identifier)
450    }
451
452    /// Retrieves metadata for a method (case-insensitive for both class and method names).
453    #[inline]
454    #[must_use]
455    pub fn get_method(&self, class: &[u8], method: &[u8]) -> Option<&FunctionLikeMetadata> {
456        let lowercase_class = ascii_lowercase_word(class);
457        let lowercase_method = ascii_lowercase_word(method);
458        self.get_method_by_words(lowercase_class, lowercase_method)
459    }
460
461    /// Retrieves metadata for a closure or arrow function by its synthetic
462    /// name (e.g. `{closure:src/foo.php:12:5}`).
463    #[inline]
464    #[must_use]
465    pub fn get_closure(&self, synthetic_name: &Word) -> Option<&FunctionLikeMetadata> {
466        self.function_likes.get(&(empty_word(), *synthetic_name))
467    }
468
469    /// Retrieves metadata for a closure declared at the given file and span.
470    /// Convenience wrapper that rebuilds the synthetic name and delegates to
471    /// [`Self::get_closure`].
472    #[inline]
473    #[must_use]
474    pub fn get_closure_at(&self, file: &File, span: Span) -> Option<&FunctionLikeMetadata> {
475        let name = crate::build_synthetic_name("closure", file, span);
476        self.get_closure(&name)
477    }
478
479    /// Retrieves method metadata by `MethodIdentifier`.
480    #[inline]
481    #[must_use]
482    pub fn get_method_by_id(&self, method_id: &MethodIdentifier) -> Option<&FunctionLikeMetadata> {
483        let lowercase_class = ascii_lowercase_word(method_id.get_class_name().as_bytes());
484        let lowercase_method = ascii_lowercase_word(method_id.get_method_name().as_bytes());
485        self.get_method_by_words(lowercase_class, lowercase_method)
486    }
487
488    /// Retrieves the declaring method metadata, following the inheritance chain.
489    /// This finds where the method is actually implemented.
490    #[inline]
491    #[must_use]
492    pub fn get_declaring_method(&self, class: &[u8], method: &[u8]) -> Option<&FunctionLikeMetadata> {
493        let method_id = MethodIdentifier::new(word(class), word(method));
494        let declaring_method_id = self.get_declaring_method_identifier(&method_id);
495        self.get_method(
496            declaring_method_id.get_class_name().as_bytes(),
497            declaring_method_id.get_method_name().as_bytes(),
498        )
499    }
500
501    /// Retrieves metadata for any function-like construct (function, method, or closure).
502    /// This is a convenience method that delegates to the appropriate getter based on the identifier type.
503    #[inline]
504    #[must_use]
505    pub fn get_function_like(
506        &self,
507        identifier: &crate::identifier::function_like::FunctionLikeIdentifier,
508    ) -> Option<&FunctionLikeMetadata> {
509        use crate::identifier::function_like::FunctionLikeIdentifier;
510        match identifier {
511            FunctionLikeIdentifier::Function(name) => self.get_function(name.as_bytes()),
512            FunctionLikeIdentifier::Method(class, method) => self.get_method(class.as_bytes(), method.as_bytes()),
513            FunctionLikeIdentifier::Closure(name) => self.get_closure(name),
514        }
515    }
516
517    /// Retrieves metadata for a global constant.
518    /// Namespace lookup is case-insensitive, constant name is case-sensitive.
519    #[inline]
520    #[must_use]
521    pub fn get_constant(&self, name: &[u8]) -> Option<&ConstantMetadata> {
522        let lowercase_name = ascii_lowercase_constant_name_word(name);
523        self.constants.get(&lowercase_name)
524    }
525
526    /// The declaration name span of a top-level symbol named `name`: a
527    /// class-like (class/interface/trait/enum), a function, or a constant.
528    ///
529    /// Prefers the symbol's name span over its full declaration span, so callers
530    /// land on the identifier rather than the whole declaration body. The lookup
531    /// is case-insensitive, like every symbol lookup here.
532    #[inline]
533    #[must_use]
534    pub fn span_of(&self, name: &[u8]) -> Option<Span> {
535        if let Some(meta) = self.get_class_like(name) {
536            return Some(meta.name_span.unwrap_or(meta.span));
537        }
538
539        if let Some(meta) = self.get_function(name) {
540            return Some(meta.name_span.unwrap_or(meta.span));
541        }
542
543        self.get_constant(name).map(|meta| meta.span)
544    }
545
546    /// Retrieves metadata for a class constant.
547    /// Class name is case-insensitive, constant name is case-sensitive.
548    #[inline]
549    #[must_use]
550    pub fn get_class_constant(&self, class: &[u8], constant: &[u8]) -> Option<&ClassLikeConstantMetadata> {
551        let lowercase_class = ascii_lowercase_word(class);
552        let constant_name = word(constant);
553        self.get_class_like_by_word(lowercase_class).and_then(|meta| meta.constants.get(&constant_name))
554    }
555
556    /// Retrieves metadata for an enum case.
557    #[inline]
558    #[must_use]
559    pub fn get_enum_case(&self, class: &[u8], case: &[u8]) -> Option<&EnumCaseMetadata> {
560        let lowercase_class = ascii_lowercase_word(class);
561        let case_name = word(case);
562        self.get_class_like_by_word(lowercase_class).and_then(|meta| meta.enum_cases.get(&case_name))
563    }
564
565    /// Retrieves metadata for a property directly from the class where it's declared.
566    /// Class name is case-insensitive, property name is case-sensitive.
567    /// Sees real declarations only; magic `@property*` tags are reachable through
568    /// `ClassLikeMetadata::magic_property_ids`.
569    #[inline]
570    #[must_use]
571    pub fn get_property(&self, class: &[u8], property: &[u8]) -> Option<&PropertyMetadata> {
572        let lowercase_class = ascii_lowercase_word(class);
573        let property_name = word(property);
574        self.get_class_like_by_word(lowercase_class)?.properties.get(&property_name)
575    }
576
577    /// Retrieves magic `@property*` metadata declared directly on a class-like.
578    /// Class name is case-insensitive, property name is case-sensitive.
579    #[inline]
580    #[must_use]
581    pub fn get_magic_property(&self, class: &[u8], property: &[u8]) -> Option<&PropertyMetadata> {
582        let lowercase_class = ascii_lowercase_word(class);
583        let property_name = word(property);
584        self.get_class_like_by_word(lowercase_class)?.magic_properties.get(&property_name)
585    }
586
587    /// Retrieves the property metadata, potentially from a parent class if inherited.
588    #[inline]
589    #[must_use]
590    pub fn get_declaring_property(&self, class: &[u8], property: &[u8]) -> Option<&PropertyMetadata> {
591        let lowercase_class = ascii_lowercase_word(class);
592        let property_name = word(property);
593        let declaring_class =
594            self.get_class_like_by_word(lowercase_class)?.declaring_property_ids.get(&property_name)?;
595        self.class_likes.get(declaring_class)?.properties.get(&property_name)
596    }
597
598    /// Retrieves magic `@property*` metadata, potentially from an inherited tag.
599    /// Class name is case-insensitive, property name is case-sensitive.
600    #[inline]
601    #[must_use]
602    pub fn get_declaring_magic_property(&self, class: &[u8], property: &[u8]) -> Option<&PropertyMetadata> {
603        let lowercase_class = ascii_lowercase_word(class);
604        let property_name = word(property);
605        let declaring_class = self.get_class_like_by_word(lowercase_class)?.magic_property_ids.get(&property_name)?;
606        self.class_likes.get(declaring_class)?.magic_properties.get(&property_name)
607    }
608    // Type Resolution
609
610    /// Gets the type of a property, resolving it from the declaring class if needed.
611    #[inline]
612    #[must_use]
613    pub fn get_property_type(&self, class: &[u8], property: &[u8]) -> Option<&TUnion> {
614        let lowercase_class = ascii_lowercase_word(class);
615        let property_name = word(property);
616        let declaring_class =
617            self.get_class_like_by_word(lowercase_class)?.declaring_property_ids.get(&property_name)?;
618        let property_meta = self.class_likes.get(declaring_class)?.properties.get(&property_name)?;
619        property_meta.type_metadata.as_ref().map(|tm| &tm.type_union)
620    }
621
622    /// Gets the type of a class constant, considering both type hints and inferred types.
623    #[must_use]
624    pub fn get_class_constant_type<'meta>(&'meta self, class: &[u8], constant: &[u8]) -> Option<Cow<'meta, TUnion>> {
625        let lowercase_class = ascii_lowercase_word(class);
626        let constant_name = word(constant);
627        let class_meta = self.get_class_like_by_word(lowercase_class)?;
628
629        // Check if it's an enum case
630        if class_meta.kind.is_enum() && class_meta.enum_cases.contains_key(&constant_name) {
631            let atomic = TAtomic::Object(TObject::new_enum_case(class_meta.original_name, constant_name));
632            return Some(Cow::Owned(TUnion::from_atomic(atomic)));
633        }
634
635        // It's a regular class constant
636        let constant_meta = class_meta.constants.get(&constant_name)?;
637
638        // Prefer the type signature if available
639        if let Some(type_meta) = constant_meta.type_metadata.as_ref() {
640            return Some(Cow::Borrowed(&type_meta.type_union));
641        }
642
643        // Fall back to inferred type
644        constant_meta.inferred_type.as_ref().map(|atomic| Cow::Owned(TUnion::from_atomic(atomic.clone())))
645    }
646    // Inheritance Queries
647
648    /// Checks if a child class extends a parent class (case-insensitive).
649    #[inline]
650    #[must_use]
651    pub fn class_extends(&self, child: &[u8], parent: &[u8]) -> bool {
652        let lowercase_child = ascii_lowercase_word(child);
653        let lowercase_parent = ascii_lowercase_word(parent);
654        let Some(metadata) = self.get_class_like_by_word(lowercase_child) else {
655            return false;
656        };
657
658        if metadata.all_parent_classes.contains(&lowercase_parent) {
659            return true;
660        }
661
662        if self.class_like_aliases.is_empty() || self.class_likes.contains_key(&lowercase_parent) {
663            return false;
664        }
665
666        self.class_like_aliases
667            .get(&lowercase_parent)
668            .is_some_and(|actual| metadata.all_parent_classes.contains(actual))
669    }
670
671    /// Checks if a class implements an interface (case-insensitive).
672    #[inline]
673    #[must_use]
674    pub fn class_implements(&self, class: &[u8], interface: &[u8]) -> bool {
675        let lowercase_class = ascii_lowercase_word(class);
676        let lowercase_interface = ascii_lowercase_word(interface);
677        let Some(metadata) = self.get_class_like_by_word(lowercase_class) else {
678            return false;
679        };
680
681        if metadata.all_parent_interfaces.contains(&lowercase_interface) {
682            return true;
683        }
684
685        if self.class_like_aliases.is_empty() || self.class_likes.contains_key(&lowercase_interface) {
686            return false;
687        }
688
689        self.class_like_aliases
690            .get(&lowercase_interface)
691            .is_some_and(|actual| metadata.all_parent_interfaces.contains(actual))
692    }
693
694    /// Checks if a class uses a trait (case-insensitive).
695    #[inline]
696    #[must_use]
697    pub fn class_uses_trait(&self, class: &[u8], trait_name: &[u8]) -> bool {
698        let lowercase_class = ascii_lowercase_word(class);
699        let lowercase_trait = ascii_lowercase_word(trait_name);
700        let Some(metadata) = self.get_class_like_by_word(lowercase_class) else {
701            return false;
702        };
703
704        if metadata.used_traits.contains(&lowercase_trait) {
705            return true;
706        }
707
708        if self.class_like_aliases.is_empty() || self.class_likes.contains_key(&lowercase_trait) {
709            return false;
710        }
711
712        self.class_like_aliases.get(&lowercase_trait).is_some_and(|actual| metadata.used_traits.contains(actual))
713    }
714
715    /// Checks if child is an instance of parent (via extends or implements).
716    #[inline]
717    #[must_use]
718    pub fn is_instance_of(&self, child: &[u8], parent: &[u8]) -> bool {
719        if child == parent {
720            return true;
721        }
722
723        let lowercase_child = ascii_lowercase_word(child);
724        let lowercase_parent = ascii_lowercase_word(parent);
725
726        if lowercase_child == lowercase_parent {
727            return true;
728        }
729
730        let Some(metadata) = self.get_class_like_by_word(lowercase_child) else {
731            return false;
732        };
733
734        let matches = |parent| {
735            metadata.name == parent
736                || metadata.all_parent_classes.contains(&parent)
737                || metadata.all_parent_interfaces.contains(&parent)
738                || metadata.used_traits.contains(&parent)
739                || metadata.require_extends.contains(&parent)
740                || metadata.require_implements.contains(&parent)
741        };
742
743        if matches(lowercase_parent) {
744            return true;
745        }
746
747        if self.class_like_aliases.is_empty() || self.class_likes.contains_key(&lowercase_parent) {
748            return false;
749        }
750
751        self.class_like_aliases.get(&lowercase_parent).is_some_and(|actual| matches(*actual))
752    }
753
754    /// Checks if the given name is an enum or final class.
755    #[inline]
756    #[must_use]
757    pub fn is_enum_or_final_class(&self, name: &[u8]) -> bool {
758        let lowercase_name = ascii_lowercase_word(name);
759        self.get_class_like_by_word(lowercase_name).is_some_and(|meta| meta.kind.is_enum() || meta.flags.is_final())
760    }
761
762    /// Checks if a class-like can be part of an intersection.
763    /// Generally, only final classes and enums cannot be intersected.
764    #[inline]
765    #[must_use]
766    pub fn is_inheritable(&self, name: &[u8]) -> bool {
767        let lowercase_name = ascii_lowercase_word(name);
768        match self.symbols.get_kind(lowercase_name) {
769            Some(SymbolKind::Class) => {
770                self.get_class_like_by_word(lowercase_name).is_some_and(|meta| !meta.flags.is_final())
771            }
772            Some(SymbolKind::Enum) => false,
773            Some(SymbolKind::Interface | SymbolKind::Trait) | None => true,
774        }
775    }
776
777    /// Gets all descendants of a class (recursive).
778    #[inline]
779    #[must_use]
780    pub fn get_class_descendants(&self, class: &[u8]) -> WordSet {
781        let lowercase_class = ascii_lowercase_word(class);
782        let lowercase_class = self.resolve_class_like_word(lowercase_class).unwrap_or(lowercase_class);
783        let mut all_descendants = WordSet::default();
784        let mut queue = vec![&lowercase_class];
785        let mut visited = WordSet::default();
786        visited.insert(lowercase_class);
787
788        while let Some(current_name) = queue.pop() {
789            if let Some(direct_descendants) = self.direct_classlike_descendants.get(current_name) {
790                for descendant in direct_descendants {
791                    if visited.insert(*descendant) {
792                        all_descendants.insert(*descendant);
793                        queue.push(descendant);
794                    }
795                }
796            }
797        }
798
799        all_descendants
800    }
801
802    /// Gets all ancestors of a class (parents + interfaces).
803    #[inline]
804    #[must_use]
805    pub fn get_class_ancestors(&self, class: &[u8]) -> WordSet {
806        let lowercase_class = ascii_lowercase_word(class);
807        let mut ancestors = WordSet::default();
808        if let Some(meta) = self.get_class_like_by_word(lowercase_class) {
809            ancestors.extend(meta.all_parent_classes.iter().copied());
810            ancestors.extend(meta.all_parent_interfaces.iter().copied());
811        }
812        ancestors
813    }
814
815    /// Gets the class where a method is declared (following inheritance).
816    #[inline]
817    #[must_use]
818    pub fn get_declaring_method_class(&self, class: &[u8], method: &[u8]) -> Option<Word> {
819        let lowercase_class = ascii_lowercase_word(class);
820        let lowercase_method = ascii_lowercase_word(method);
821
822        self.get_class_like_by_word(lowercase_class)?
823            .declaring_method_ids
824            .get(&lowercase_method)
825            .map(|method_id| method_id.get_class_name())
826    }
827
828    /// Gets the declaring method identifier for a method.
829    #[must_use]
830    pub fn get_declaring_method_identifier(&self, method_id: &MethodIdentifier) -> MethodIdentifier {
831        let lowercase_class = ascii_lowercase_word(method_id.get_class_name().as_bytes());
832        let lowercase_method = ascii_lowercase_word(method_id.get_method_name().as_bytes());
833
834        let Some(class_meta) = self.get_class_like_by_word(lowercase_class) else {
835            return *method_id;
836        };
837
838        if let Some(declaring_method_id) = class_meta.declaring_method_ids.get(&lowercase_method) {
839            return *declaring_method_id;
840        }
841
842        if class_meta.flags.is_abstract()
843            && let Some(overridden_map) = class_meta.overridden_method_ids.get(&lowercase_method)
844            && let Some((_, first_method_id)) = overridden_map.first()
845        {
846            return *first_method_id;
847        }
848
849        *method_id
850    }
851
852    /// Checks if a method is overriding a parent method.
853    #[inline]
854    #[must_use]
855    pub fn method_is_overriding(&self, class: &[u8], method: &[u8]) -> bool {
856        let lowercase_class = ascii_lowercase_word(class);
857        let lowercase_method = ascii_lowercase_word(method);
858        self.get_class_like_by_word(lowercase_class)
859            .is_some_and(|meta| meta.overridden_method_ids.contains_key(&lowercase_method))
860    }
861
862    /// Checks if a method is abstract.
863    #[inline]
864    #[must_use]
865    pub fn method_is_abstract(&self, class: &[u8], method: &[u8]) -> bool {
866        let lowercase_class = ascii_lowercase_word(class);
867        let lowercase_method = ascii_lowercase_word(method);
868        self.get_method_by_words(lowercase_class, lowercase_method)
869            .and_then(|meta| meta.method_metadata.as_ref())
870            .is_some_and(|method_meta| method_meta.is_abstract)
871    }
872
873    /// Checks if a method is final.
874    #[inline]
875    #[must_use]
876    pub fn method_is_final(&self, class: &[u8], method: &[u8]) -> bool {
877        let lowercase_class = ascii_lowercase_word(class);
878        let lowercase_method = ascii_lowercase_word(method);
879        self.get_method_by_words(lowercase_class, lowercase_method)
880            .and_then(|meta| meta.method_metadata.as_ref())
881            .is_some_and(|method_meta| method_meta.is_final)
882    }
883
884    /// Gets the effective visibility of a method, taking into account trait alias visibility overrides.
885    ///
886    /// When a trait method is aliased with a visibility modifier (e.g., `use Trait { method as public aliasedMethod; }`),
887    /// the visibility is stored in the class's `trait_visibility_map`. This method checks that map first,
888    /// then falls back to the method's declared visibility.
889    #[inline]
890    #[must_use]
891    pub fn get_method_visibility(&self, class: &[u8], method: &[u8]) -> Option<Visibility> {
892        let lowercase_class = ascii_lowercase_word(class);
893        let lowercase_method = ascii_lowercase_word(method);
894
895        // First check if there's a trait visibility override for this method
896        if let Some(class_meta) = self.get_class_like_by_word(lowercase_class)
897            && let Some(overridden_visibility) = class_meta.trait_visibility_map.get(&lowercase_method)
898        {
899            return Some(*overridden_visibility);
900        }
901
902        // Fall back to the method's declared visibility
903        let declaring_class = self.get_declaring_method_class(class, method)?;
904        let identifier = (declaring_class, lowercase_method);
905
906        self.function_likes
907            .get(&identifier)
908            .and_then(|meta| meta.method_metadata.as_ref())
909            .map(|method_meta| method_meta.visibility)
910    }
911
912    /// Gets thrown types for a function-like, including inherited throws.
913    #[must_use]
914    pub fn get_function_like_thrown_types<'meta>(
915        &'meta self,
916        class_like: Option<&'meta ClassLikeMetadata>,
917        function_like: &'meta FunctionLikeMetadata,
918    ) -> &'meta [TypeMetadata] {
919        if !function_like.thrown_types.is_empty() {
920            return function_like.thrown_types.as_slice();
921        }
922
923        if !function_like.kind.is_method() {
924            return &[];
925        }
926
927        let Some(class_like) = class_like else {
928            return &[];
929        };
930
931        let method_name = &function_like.name;
932
933        if let Some(overridden_map) = class_like.overridden_method_ids.get(method_name) {
934            for (parent_class_name, parent_method_id) in overridden_map {
935                if class_like.name.as_bytes().eq_ignore_ascii_case(parent_class_name.as_bytes()) {
936                    continue; // Skip self-recursion if the method overrides itself
937                }
938
939                let Some(parent_class) = self.class_likes.get(parent_class_name) else {
940                    continue;
941                };
942
943                let parent_method_key = (parent_method_id.get_class_name(), parent_method_id.get_method_name());
944                if let Some(parent_method) = self.function_likes.get(&parent_method_key) {
945                    let thrown = self.get_function_like_thrown_types(Some(parent_class), parent_method);
946                    if !thrown.is_empty() {
947                        return thrown;
948                    }
949                }
950            }
951        }
952
953        &[]
954    }
955
956    /// Gets the class where a property is declared.
957    /// Sees real declarations only; magic `@property*` tags are reachable through
958    /// `ClassLikeMetadata::magic_property_ids`.
959    #[inline]
960    #[must_use]
961    pub fn get_declaring_property_class(&self, class: &[u8], property: &[u8]) -> Option<Word> {
962        let lowercase_class = ascii_lowercase_word(class);
963        let property_name = word(property);
964        self.get_class_like_by_word(lowercase_class)?.declaring_property_ids.get(&property_name).copied()
965    }
966
967    /// Gets all descendants of a class (recursive).
968    #[must_use]
969    pub fn get_all_descendants(&self, class: &[u8]) -> WordSet {
970        let lowercase_class = ascii_lowercase_word(class);
971        let lowercase_class = self.resolve_class_like_word(lowercase_class).unwrap_or(lowercase_class);
972        let mut all_descendants = WordSet::default();
973        let mut queue = vec![&lowercase_class];
974        let mut visited = WordSet::default();
975        visited.insert(lowercase_class);
976
977        while let Some(current_name) = queue.pop() {
978            if let Some(direct_descendants) = self.direct_classlike_descendants.get(current_name) {
979                for descendant in direct_descendants {
980                    if visited.insert(*descendant) {
981                        all_descendants.insert(*descendant);
982                        queue.push(descendant);
983                    }
984                }
985            }
986        }
987
988        all_descendants
989    }
990
991    /// Generates the synthetic display name for an anonymous class based on
992    /// its declaring file and span. Delegates to [`crate::get_anonymous_class_name`].
993    #[must_use]
994    pub fn get_anonymous_class_name(file: &File, span: Span) -> Word {
995        crate::get_anonymous_class_name(file, span)
996    }
997
998    /// Retrieves the metadata for an anonymous class based on its declaring
999    /// file and span.
1000    #[must_use]
1001    pub fn get_anonymous_class(&self, file: &File, span: Span) -> Option<&ClassLikeMetadata> {
1002        let name = Self::get_anonymous_class_name(file, span);
1003        self.get_class_like(name.as_bytes())
1004    }
1005
1006    /// Gets the file signature for a given file ID.
1007    ///
1008    /// # Arguments
1009    ///
1010    /// * `file_id` - The file identifier
1011    ///
1012    /// # Returns
1013    ///
1014    /// A reference to the `FileSignature` if it exists, or `None` if the file has no signature.
1015    #[inline]
1016    #[must_use]
1017    pub fn get_file_signature(&self, file_id: &FileId) -> Option<&FileSignature> {
1018        self.file_signatures.get(file_id)
1019    }
1020
1021    /// Adds or updates a file signature for a given file ID.
1022    ///
1023    /// # Arguments
1024    ///
1025    /// * `file_id` - The file identifier
1026    /// * `signature` - The file signature
1027    ///
1028    /// # Returns
1029    ///
1030    /// The previous `FileSignature` if it existed.
1031    #[inline]
1032    pub fn set_file_signature(&mut self, file_id: FileId, signature: FileSignature) -> Option<FileSignature> {
1033        self.file_signatures.insert(file_id, signature)
1034    }
1035
1036    /// Marks safe symbols based on diff and invalidation cascade.
1037    ///
1038    /// After this function runs, `self.safe_symbols` and `self.safe_symbol_members`
1039    /// will contain all symbols that can be safely skipped during analysis.
1040    ///
1041    /// # Arguments
1042    ///
1043    /// * `diff` - The computed diff between old and new code
1044    /// * `references` - Symbol reference graph from previous run
1045    ///
1046    /// # Returns
1047    /// Returns the logical names of files whose top-level code references an invalidated
1048    /// symbol. Returns `None` if the cascade was too large to compute.
1049    pub fn mark_safe_symbols(&mut self, diff: &CodebaseDiff, references: &SymbolReferences) -> Option<WordSet> {
1050        let (invalid_symbols, partially_invalid, invalid_files) = references.get_invalid_symbols(diff)?;
1051
1052        // Mark all symbols in 'keep' set as safe (unless invalidated by cascade)
1053        for keep_symbol in diff.get_keep() {
1054            if !invalid_symbols.contains(keep_symbol) {
1055                if keep_symbol.1.is_empty() {
1056                    // Top-level symbol (class, function, constant)
1057                    if !partially_invalid.contains(&keep_symbol.0) {
1058                        self.safe_symbols.insert(keep_symbol.0);
1059                    }
1060                } else {
1061                    // Member (method, property, class constant)
1062                    self.safe_symbol_members.insert(*keep_symbol);
1063                }
1064            }
1065        }
1066
1067        Some(invalid_files)
1068    }
1069
1070    /// Merges information from another `CodebaseMetadata` into this one.
1071    ///
1072    /// When both metadata have the same priority, the one with the smaller span is kept
1073    /// for deterministic results regardless of scan order.
1074    pub fn extend(&mut self, other: CodebaseMetadata) {
1075        let class_likes_changed = !other.class_likes.is_empty();
1076        for (k, mut v) in other.class_likes {
1077            match self.class_likes.entry(k) {
1078                Entry::Occupied(mut entry) => {
1079                    if should_replace_metadata(entry.get().flags, entry.get().span, v.flags, v.span) {
1080                        v.version_constraint.merge(entry.get().version_constraint.clone());
1081                        entry.insert(v);
1082                    } else {
1083                        entry.get_mut().version_constraint.merge(v.version_constraint);
1084                    }
1085                }
1086                Entry::Vacant(entry) => {
1087                    entry.insert(v);
1088                }
1089            }
1090        }
1091
1092        if class_likes_changed && !self.class_like_alias_declarations.is_empty() {
1093            self.class_like_aliases_dirty = true;
1094        }
1095
1096        self.merge_class_like_alias_declarations(other.class_like_alias_declarations);
1097
1098        for (k, mut v) in other.function_likes {
1099            match self.function_likes.entry(k) {
1100                Entry::Occupied(mut entry) => {
1101                    if should_replace_metadata(entry.get().flags, entry.get().span, v.flags, v.span) {
1102                        v.version_constraint.merge(entry.get().version_constraint.clone());
1103                        entry.insert(v);
1104                    } else {
1105                        entry.get_mut().version_constraint.merge(v.version_constraint);
1106                    }
1107                }
1108                Entry::Vacant(entry) => {
1109                    entry.insert(v);
1110                }
1111            }
1112        }
1113
1114        for (k, mut v) in other.constants {
1115            match self.constants.entry(k) {
1116                Entry::Occupied(mut entry) => {
1117                    if should_replace_metadata(entry.get().flags, entry.get().span, v.flags, v.span) {
1118                        v.version_constraint.merge(entry.get().version_constraint.clone());
1119                        entry.insert(v);
1120                    } else {
1121                        entry.get_mut().version_constraint.merge(v.version_constraint);
1122                    }
1123                }
1124                Entry::Vacant(entry) => {
1125                    entry.insert(v);
1126                }
1127            }
1128        }
1129
1130        self.symbols.extend(other.symbols);
1131
1132        for (k, v) in other.all_class_like_descendants {
1133            self.all_class_like_descendants.entry(k).or_default().extend(v);
1134        }
1135
1136        for (k, v) in other.direct_classlike_descendants {
1137            self.direct_classlike_descendants.entry(k).or_default().extend(v);
1138        }
1139
1140        self.file_signatures.extend(other.file_signatures);
1141        self.safe_symbols.extend(other.safe_symbols);
1142        self.safe_symbol_members.extend(other.safe_symbol_members);
1143        self.infer_types_from_usage |= other.infer_types_from_usage;
1144        self.merge_patch_class_likes(other.patch_class_likes);
1145        self.merge_patch_function_likes(other.patch_function_likes);
1146        self.merge_patch_constants(other.patch_constants);
1147    }
1148
1149    /// Extends this codebase with another by reference, cloning only individual entries.
1150    ///
1151    /// This is more efficient than `extend(other.clone())` because it avoids allocating
1152    /// a full clone of the source metadata's outer HashMap/WordMap structures. Only
1153    /// individual entries that need insertion are cloned.
1154    pub fn extend_ref(&mut self, other: &CodebaseMetadata) {
1155        let class_likes_changed = !other.class_likes.is_empty();
1156        for (k, v) in &other.class_likes {
1157            match self.class_likes.entry(*k) {
1158                Entry::Occupied(mut entry) => {
1159                    if should_replace_metadata(entry.get().flags, entry.get().span, v.flags, v.span) {
1160                        let mut new = v.clone();
1161                        new.version_constraint.merge(entry.get().version_constraint.clone());
1162                        entry.insert(new);
1163                    } else {
1164                        entry.get_mut().version_constraint.merge(v.version_constraint.clone());
1165                    }
1166                }
1167                Entry::Vacant(entry) => {
1168                    entry.insert(v.clone());
1169                }
1170            }
1171        }
1172
1173        if class_likes_changed && !self.class_like_alias_declarations.is_empty() {
1174            self.class_like_aliases_dirty = true;
1175        }
1176
1177        self.merge_class_like_alias_declarations(
1178            other.class_like_alias_declarations.iter().map(|(alias, declaration)| (*alias, *declaration)),
1179        );
1180
1181        for (k, v) in &other.function_likes {
1182            match self.function_likes.entry(*k) {
1183                Entry::Occupied(mut entry) => {
1184                    if should_replace_metadata(entry.get().flags, entry.get().span, v.flags, v.span) {
1185                        let mut new = v.clone();
1186                        new.version_constraint.merge(entry.get().version_constraint.clone());
1187                        entry.insert(new);
1188                    } else {
1189                        entry.get_mut().version_constraint.merge(v.version_constraint.clone());
1190                    }
1191                }
1192                Entry::Vacant(entry) => {
1193                    entry.insert(v.clone());
1194                }
1195            }
1196        }
1197
1198        for (k, v) in &other.constants {
1199            match self.constants.entry(*k) {
1200                Entry::Occupied(mut entry) => {
1201                    if should_replace_metadata(entry.get().flags, entry.get().span, v.flags, v.span) {
1202                        let mut new = v.clone();
1203                        new.version_constraint.merge(entry.get().version_constraint.clone());
1204                        entry.insert(new);
1205                    } else {
1206                        entry.get_mut().version_constraint.merge(v.version_constraint.clone());
1207                    }
1208                }
1209                Entry::Vacant(entry) => {
1210                    entry.insert(v.clone());
1211                }
1212            }
1213        }
1214
1215        self.symbols.extend_ref(&other.symbols);
1216
1217        for (k, v) in &other.all_class_like_descendants {
1218            self.all_class_like_descendants.entry(*k).or_default().extend(v.iter().copied());
1219        }
1220
1221        for (k, v) in &other.direct_classlike_descendants {
1222            self.direct_classlike_descendants.entry(*k).or_default().extend(v.iter().copied());
1223        }
1224
1225        for (k, v) in &other.file_signatures {
1226            self.file_signatures.insert(*k, v.clone());
1227        }
1228        self.safe_symbols.extend(other.safe_symbols.iter().copied());
1229        self.safe_symbol_members.extend(other.safe_symbol_members.iter().copied());
1230        self.infer_types_from_usage |= other.infer_types_from_usage;
1231        self.merge_patch_class_likes(other.patch_class_likes.iter().map(|(k, v)| (*k, v.clone())));
1232        self.merge_patch_function_likes(other.patch_function_likes.iter().map(|(k, v)| (*k, v.clone())));
1233        self.merge_patch_constants(other.patch_constants.iter().map(|(k, v)| (*k, v.clone())));
1234    }
1235
1236    fn merge_class_like_alias_declarations(
1237        &mut self,
1238        declarations: impl IntoIterator<Item = (Word, (Word, Span, MetadataFlags))>,
1239    ) {
1240        for (alias, (target, span, flags)) in declarations {
1241            self.add_class_like_alias(alias, target, span, flags);
1242        }
1243    }
1244
1245    /// Merges patch class-likes from another codebase, diagnosing collisions.
1246    ///
1247    /// At most one patch may target a given symbol. When two patches collide, the first-merged
1248    /// entry is kept and a [`PatchDuplicateTarget`](ScanningIssueKind::PatchDuplicateTarget)
1249    /// diagnostic referencing both sites is attached to it, rather than letting one silently
1250    /// overwrite the other in hash-order.
1251    fn merge_patch_class_likes(&mut self, incoming: impl IntoIterator<Item = (Word, ClassLikeMetadata)>) {
1252        for (k, v) in incoming {
1253            match self.patch_class_likes.entry(k) {
1254                Entry::Occupied(mut entry) => {
1255                    let diagnostic = duplicate_patch_class_diagnostic(entry.get(), &v);
1256                    entry.get_mut().issues.push(diagnostic);
1257                }
1258                Entry::Vacant(entry) => {
1259                    entry.insert(v);
1260                }
1261            }
1262        }
1263    }
1264
1265    /// Merges patch function-likes from another codebase, diagnosing collisions on free
1266    /// functions. Method collisions are subsumed by the enclosing class's duplicate
1267    /// diagnostic, so only keys with an empty class component are reported here.
1268    fn merge_patch_function_likes(&mut self, incoming: impl IntoIterator<Item = ((Word, Word), FunctionLikeMetadata)>) {
1269        for (k, v) in incoming {
1270            match self.patch_function_likes.entry(k) {
1271                Entry::Occupied(mut entry) => {
1272                    if k.0.is_empty() {
1273                        let diagnostic = duplicate_patch_function_diagnostic(entry.get(), &v);
1274                        entry.get_mut().issues.push(diagnostic);
1275                    }
1276                }
1277                Entry::Vacant(entry) => {
1278                    entry.insert(v);
1279                }
1280            }
1281        }
1282    }
1283
1284    /// Merges patch constants from another codebase, diagnosing collisions.
1285    fn merge_patch_constants(&mut self, incoming: impl IntoIterator<Item = (Word, ConstantMetadata)>) {
1286        for (k, v) in incoming {
1287            match self.patch_constants.entry(k) {
1288                Entry::Occupied(mut entry) => {
1289                    let diagnostic = duplicate_patch_constant_diagnostic(entry.get(), &v);
1290                    entry.get_mut().issues.push(diagnostic);
1291                }
1292                Entry::Vacant(entry) => {
1293                    entry.insert(v);
1294                }
1295            }
1296        }
1297    }
1298
1299    /// Moves every scanned entry of this per-file partial into the patch maps.
1300    ///
1301    /// Called on a per-file partial right after `scan_program` when the file is a
1302    /// [`FileType::Patch`]. Symbols and descendants from a patch partial are dropped — the
1303    /// FQCN belongs to whichever non-patch source originally declared it (or it's an orphan
1304    /// which `apply_patches_pass` will diagnose later).
1305    pub fn convert_partial_to_patch(&mut self) {
1306        for (k, v) in std::mem::take(&mut self.class_likes) {
1307            self.patch_class_likes.insert(k, v);
1308        }
1309
1310        for (k, v) in std::mem::take(&mut self.function_likes) {
1311            self.patch_function_likes.insert(k, v);
1312        }
1313
1314        for (k, v) in std::mem::take(&mut self.constants) {
1315            self.patch_constants.insert(k, v);
1316        }
1317
1318        self.symbols = Symbols::new();
1319        self.class_like_aliases.clear();
1320        self.class_like_alias_declarations.clear();
1321        self.class_like_aliases_dirty = false;
1322        self.all_class_like_descendants.clear();
1323        self.direct_classlike_descendants.clear();
1324    }
1325
1326    /// Folds every entry in the `patch_*` maps into the matching vendor / built-in entry,
1327    /// attaching validation diagnostics to the patch entry's `issues` list.
1328    ///
1329    /// At most one patch may target a given symbol, so each entry is applied directly to its
1330    /// target. A patch whose target is user-defined is inert (user definitions win); a patch
1331    /// with no matching target is diagnosed as an orphan.
1332    ///
1333    /// Must be called after all partials have been merged so the slots patches target are
1334    /// present.
1335    pub fn apply_patches_pass(&mut self) {
1336        // `(class, method)` slots where a patch overrides a method inherited from an ancestor.
1337        // No function-like exists at these keys yet, so the function loop below materializes
1338        // them from the patch's own scanned declaration rather than treating them as orphans.
1339        let mut inherited_overrides: HashSet<(Word, Word)> = HashSet::default();
1340
1341        let class_keys: Vec<Word> = self.patch_class_likes.keys().copied().collect();
1342        for fqcn in class_keys {
1343            let Some(target) = self.class_likes.get(&fqcn) else {
1344                if let Some(p) = self.patch_class_likes.get_mut(&fqcn) {
1345                    let diag = orphan_patch_class_diagnostic(p);
1346                    p.issues.push(diag);
1347                }
1348                continue;
1349            };
1350            // User-defined targets win; the patch entry is inert. Leave any scan-time issues
1351            // on it intact — they still belong to the patch source.
1352            if target.flags.is_user_defined() {
1353                continue;
1354            }
1355
1356            let mut working = target.clone();
1357            let inherited =
1358                collect_inherited_patch_methods(&working, &self.patch_class_likes[&fqcn], &self.class_likes);
1359            inherited_overrides.extend(inherited.iter().map(|method| (fqcn, *method)));
1360            if let Some(patch_entry) = self.patch_class_likes.get_mut(&fqcn) {
1361                working.apply_patch(patch_entry, &inherited);
1362            }
1363            self.class_likes.insert(fqcn, working);
1364        }
1365
1366        let func_keys: Vec<(Word, Word)> = self.patch_function_likes.keys().copied().collect();
1367        for key in func_keys {
1368            let Some(target) = self.function_likes.get(&key) else {
1369                if inherited_overrides.contains(&key) {
1370                    // The patch overrides a method inherited from an ancestor, so no slot exists
1371                    // at `(class, method)` yet. The patch file declares the method in full, so
1372                    // promote its scanned function-like as this class's own declaration; the
1373                    // class loop has already pointed the declaring/appearing ids at this slot.
1374                    if let Some(p) = self.patch_function_likes.get(&key) {
1375                        let materialized = p.clone();
1376                        self.function_likes.insert(key, materialized);
1377                    }
1378                    continue;
1379                }
1380                // Methods of an orphan patch class are covered by the class-level diagnostic;
1381                // only free functions need their own orphan diagnostic.
1382                if key.0.is_empty()
1383                    && let Some(p) = self.patch_function_likes.get_mut(&key)
1384                {
1385                    let diag = orphan_patch_function_diagnostic(p);
1386                    p.issues.push(diag);
1387                }
1388                continue;
1389            };
1390            if target.flags.is_user_defined() {
1391                continue;
1392            }
1393
1394            let mut working = target.clone();
1395            if let Some(patch_entry) = self.patch_function_likes.get_mut(&key) {
1396                working.apply_patch(patch_entry);
1397            }
1398            self.function_likes.insert(key, working);
1399        }
1400
1401        let const_keys: Vec<Word> = self.patch_constants.keys().copied().collect();
1402        for fqcn in const_keys {
1403            let Some(target) = self.constants.get(&fqcn) else {
1404                if let Some(p) = self.patch_constants.get_mut(&fqcn) {
1405                    let diag = orphan_patch_constant_diagnostic(p);
1406                    p.issues.push(diag);
1407                }
1408                continue;
1409            };
1410            if target.flags.is_user_defined() {
1411                continue;
1412            }
1413
1414            let mut working = target.clone();
1415            if let Some(patch_entry) = self.patch_constants.get(&fqcn) {
1416                working.apply_patch(patch_entry);
1417            }
1418            self.constants.insert(fqcn, working);
1419        }
1420    }
1421
1422    /// Extracts only the keys that this per-file metadata currently "owns" in the given
1423    /// merged codebase; i.e. keys whose span in `merged` matches this metadata's span.
1424    ///
1425    /// This is what you want for incremental fingerprints. [`extract_keys`](Self::extract_keys)
1426    /// captures *every* key the scan produced, including ones that lost the tiebreak in
1427    /// [`extend`](Self::extend) / [`extend_ref`](Self::extend_ref) when another file defined
1428    /// the same FQN. Using `extract_keys` as a removal fingerprint then causes a nasty
1429    /// cross-file bug: touching file *B* can remove an entry that file *A* actually owns,
1430    /// because [`remove_entries_by_keys`](Self::remove_entries_by_keys) deletes by FQN
1431    /// without checking who the current owner is. The analyzer then reports a spurious
1432    /// "duplicate definition" when it walks *A* and finds *B*'s span in the codebase.
1433    ///
1434    /// By only recording the keys whose spans still match *this* metadata, removing the
1435    /// fingerprint later becomes a safe no-op when another file won the merge. The
1436    /// removal only drops the entries this file genuinely put into the merged codebase.
1437    #[must_use]
1438    pub fn extract_owned_keys(&self, merged: &CodebaseMetadata) -> CodebaseEntryKeys {
1439        let class_like_names = self
1440            .class_likes
1441            .iter()
1442            .filter(|(name, meta)| merged.class_likes.get(*name).is_some_and(|m| m.span == meta.span))
1443            .map(|(name, _)| *name)
1444            .collect();
1445
1446        let class_like_aliases = self
1447            .class_like_alias_declarations
1448            .iter()
1449            .filter(|(name, (_, span, _))| {
1450                merged.class_like_alias_declarations.get(*name).is_some_and(|(_, merged_span, _)| merged_span == span)
1451            })
1452            .map(|(alias, (target, span, _))| (*alias, *target, *span))
1453            .collect();
1454
1455        let function_like_keys = self
1456            .function_likes
1457            .iter()
1458            .filter(|(key, meta)| merged.function_likes.get(*key).is_some_and(|m| m.span == meta.span))
1459            .map(|(key, _)| *key)
1460            .collect();
1461
1462        let constant_names = self
1463            .constants
1464            .iter()
1465            .filter(|(name, meta)| merged.constants.get(*name).is_some_and(|m| m.span == meta.span))
1466            .map(|(name, _)| *name)
1467            .collect();
1468
1469        // A file signature is always owned by its file (there is at most one per file).
1470        let file_ids = self.file_signatures.keys().copied().collect();
1471
1472        CodebaseEntryKeys { class_like_names, class_like_aliases, function_like_keys, constant_names, file_ids }
1473    }
1474
1475    /// Removes entries whose keys match the given [`CodebaseEntryKeys`].
1476    ///
1477    /// This is the lightweight equivalent of [`remove_entries_of()`] — it performs the
1478    /// same removals but from a compact key set instead of a full `CodebaseMetadata` reference.
1479    pub fn remove_entries_by_keys(&mut self, keys: &CodebaseEntryKeys) {
1480        if !keys.class_like_names.is_empty() && !self.class_like_alias_declarations.is_empty() {
1481            self.class_like_aliases_dirty = true;
1482        }
1483
1484        for k in &keys.class_like_names {
1485            self.class_likes.remove(k);
1486            self.symbols.remove(*k);
1487        }
1488
1489        for (alias, _, _) in &keys.class_like_aliases {
1490            self.class_like_alias_declarations.remove(alias);
1491            self.class_like_aliases_dirty = true;
1492        }
1493
1494        for k in &keys.function_like_keys {
1495            self.function_likes.remove(k);
1496        }
1497
1498        for k in &keys.constant_names {
1499            self.constants.remove(k);
1500        }
1501
1502        for k in &keys.file_ids {
1503            self.file_signatures.remove(k);
1504        }
1505
1506        // Drop any patch entry that originated from a file signature we just removed; a patch
1507        // entry's originating file is recorded on its span.
1508        let removed_files: HashSet<FileId> = keys.file_ids.iter().copied().collect();
1509        self.patch_class_likes.retain(|_, m| !removed_files.contains(&m.span.file_id));
1510        self.patch_function_likes.retain(|_, m| !removed_files.contains(&m.span.file_id));
1511        self.patch_constants.retain(|_, m| !removed_files.contains(&m.span.file_id));
1512    }
1513
1514    /// Takes all issues from the codebase metadata.
1515    pub fn take_issues(&mut self, user_defined: bool) -> IssueCollection {
1516        let mut issues = IssueCollection::new();
1517
1518        for meta in self.class_likes.values_mut() {
1519            if user_defined && !meta.flags.is_user_defined() {
1520                continue;
1521            }
1522            issues.extend(meta.take_issues());
1523        }
1524
1525        for meta in self.function_likes.values_mut() {
1526            if user_defined && !meta.flags.is_user_defined() {
1527                continue;
1528            }
1529            issues.extend(meta.take_issues());
1530        }
1531
1532        for meta in self.constants.values_mut() {
1533            if user_defined && !meta.flags.is_user_defined() {
1534                continue;
1535            }
1536            issues.extend(meta.take_issues());
1537        }
1538
1539        // Patches are user-authored, so their issues are always reported regardless of the
1540        // `user_defined` filter. They live in their own maps and never appear in the regular
1541        // class_likes/function_likes/constants iteration above.
1542        for meta in self.patch_class_likes.values_mut() {
1543            issues.extend(meta.take_issues());
1544        }
1545
1546        for meta in self.patch_function_likes.values_mut() {
1547            issues.extend(meta.take_issues());
1548        }
1549
1550        for meta in self.patch_constants.values_mut() {
1551            issues.extend(meta.take_issues());
1552        }
1553
1554        issues
1555    }
1556
1557    /// Gets all file IDs that have signatures in this metadata.
1558    ///
1559    /// This is a helper method for incremental analysis to iterate over all files.
1560    #[must_use]
1561    pub fn get_all_file_ids(&self) -> Vec<FileId> {
1562        self.file_signatures.keys().copied().collect()
1563    }
1564}
1565
1566/// Returns the subset of methods declared by `patch` that are inherited by `target` from
1567/// an ancestor but not declared on `target` itself. Used by `apply_patch` on class-like
1568/// metadata to distinguish patch-declared overrides of inherited methods (allowed) from
1569/// patch-introduced new methods (disallowed).
1570fn collect_inherited_patch_methods(
1571    target: &ClassLikeMetadata,
1572    patch: &ClassLikeMetadata,
1573    class_likes: &WordMap<ClassLikeMetadata>,
1574) -> WordSet {
1575    if patch.methods.is_empty() {
1576        return WordSet::default();
1577    }
1578    let ancestor_methods = class_like::collect_ancestor_methods(target, class_likes);
1579    patch.methods.iter().filter(|m| ancestor_methods.contains(*m)).copied().collect()
1580}
1581
1582fn duplicate_patch_class_diagnostic(kept: &ClassLikeMetadata, dropped: &ClassLikeMetadata) -> Issue {
1583    Issue::error(format!(
1584        "Multiple patches target `{}`; at most one patch may target a given symbol.",
1585        kept.original_name
1586    ))
1587    .with_code(ScanningIssueKind::PatchDuplicateTarget)
1588    .with_annotation(Annotation::primary(dropped.span).with_message("Duplicate patch for this symbol."))
1589    .with_annotation(Annotation::secondary(kept.span).with_message("Already patched here."))
1590    .with_help("Merge the conflicting declarations into a single patch, or remove all but one.")
1591}
1592
1593fn duplicate_patch_function_diagnostic(kept: &FunctionLikeMetadata, dropped: &FunctionLikeMetadata) -> Issue {
1594    Issue::error(format!(
1595        "Multiple patches target function `{}`; at most one patch may target a given symbol.",
1596        kept.name
1597    ))
1598    .with_code(ScanningIssueKind::PatchDuplicateTarget)
1599    .with_annotation(Annotation::primary(dropped.span).with_message("Duplicate patch for this function."))
1600    .with_annotation(Annotation::secondary(kept.span).with_message("Already patched here."))
1601    .with_help("Merge the conflicting declarations into a single patch, or remove all but one.")
1602}
1603
1604fn duplicate_patch_constant_diagnostic(kept: &ConstantMetadata, dropped: &ConstantMetadata) -> Issue {
1605    Issue::error(format!(
1606        "Multiple patches target constant `{}`; at most one patch may target a given symbol.",
1607        kept.name
1608    ))
1609    .with_code(ScanningIssueKind::PatchDuplicateTarget)
1610    .with_annotation(Annotation::primary(dropped.span).with_message("Duplicate patch for this constant."))
1611    .with_annotation(Annotation::secondary(kept.span).with_message("Already patched here."))
1612    .with_help("Merge the conflicting declarations into a single patch, or remove all but one.")
1613}
1614
1615fn orphan_patch_class_diagnostic(meta: &ClassLikeMetadata) -> Issue {
1616    Issue::error(format!(
1617        "Patch declares `{}` but no vendored or built-in definition exists to patch.",
1618        meta.original_name,
1619    ))
1620    .with_code(ScanningIssueKind::PatchIntroducesNewSymbol)
1621    .with_annotation(Annotation::primary(meta.span))
1622    .with_help(
1623        "The patch may be misnamed or out-of-date relative to the vendored or built-in definition; \
1624         check the symbol name and verify the patch still matches the upstream source.",
1625    )
1626}
1627
1628fn orphan_patch_function_diagnostic(meta: &FunctionLikeMetadata) -> Issue {
1629    Issue::error(format!(
1630        "Patch declares function `{}` but no vendored or built-in definition exists to patch.",
1631        meta.name,
1632    ))
1633    .with_code(ScanningIssueKind::PatchIntroducesNewSymbol)
1634    .with_annotation(Annotation::primary(meta.span))
1635    .with_help(
1636        "The patch may be misnamed or out-of-date relative to the vendored or built-in definition; \
1637         check the function name and verify the patch still matches the upstream source.",
1638    )
1639}
1640
1641fn orphan_patch_constant_diagnostic(meta: &ConstantMetadata) -> Issue {
1642    Issue::error(format!(
1643        "Patch declares constant `{}` but no vendored or built-in definition exists to patch.",
1644        meta.name,
1645    ))
1646    .with_code(ScanningIssueKind::PatchIntroducesNewSymbol)
1647    .with_annotation(Annotation::primary(meta.span))
1648    .with_help(
1649        "The patch may be misnamed or out-of-date relative to the vendored or built-in definition; \
1650         check the constant name and verify the patch still matches the upstream source.",
1651    )
1652}
1653
1654/// Determines which metadata value to keep when merging duplicates.
1655///
1656/// Priority:
1657///   1. user-defined > patch > external > built-in > other.
1658///   2. non-polyfill > polyfill — tools like rector/phpstan/psalm ship
1659///      skeleton stubs gated by `if (!class_exists('X'))` that should never
1660///      shadow a concrete definition.
1661///   3. smaller span wins as a deterministic tie-breaker.
1662///
1663/// Returns `true` if the new value should replace the existing one.
1664fn should_replace_metadata(
1665    existing_flags: MetadataFlags,
1666    existing_span: Span,
1667    new_flags: MetadataFlags,
1668    new_span: Span,
1669) -> bool {
1670    let new_is_user_defined = new_flags.is_user_defined();
1671    let existing_is_user_defined = existing_flags.is_user_defined();
1672
1673    if new_is_user_defined != existing_is_user_defined {
1674        return new_is_user_defined;
1675    }
1676
1677    let new_is_patch = new_flags.is_patch();
1678    let existing_is_patch = existing_flags.is_patch();
1679
1680    if new_is_patch != existing_is_patch {
1681        return new_is_patch;
1682    }
1683
1684    let new_is_external = new_flags.is_external();
1685    let existing_is_external = existing_flags.is_external();
1686
1687    if new_is_external != existing_is_external {
1688        return new_is_external;
1689    }
1690
1691    let new_is_built_in = new_flags.is_built_in();
1692    let existing_is_built_in = existing_flags.is_built_in();
1693
1694    if new_is_built_in != existing_is_built_in {
1695        return new_is_built_in;
1696    }
1697
1698    let new_is_polyfill = new_flags.is_polyfill();
1699    let existing_is_polyfill = existing_flags.is_polyfill();
1700
1701    if new_is_polyfill != existing_is_polyfill {
1702        return !new_is_polyfill;
1703    }
1704
1705    new_span < existing_span
1706}
1707
1708#[cfg(test)]
1709mod should_replace_metadata_tests {
1710    use super::*;
1711
1712    #[test]
1713    fn non_polyfill_replaces_polyfill() {
1714        let polyfill = MetadataFlags::POLYFILL;
1715        let real = MetadataFlags::empty();
1716        assert!(should_replace_metadata(polyfill, Span::dummy(0, 100), real, Span::dummy(0, 100)));
1717        assert!(!should_replace_metadata(real, Span::dummy(0, 100), polyfill, Span::dummy(0, 100)));
1718    }
1719
1720    #[test]
1721    fn polyfill_does_not_replace_non_polyfill_even_with_smaller_span() {
1722        let real = MetadataFlags::empty();
1723        let polyfill = MetadataFlags::POLYFILL;
1724        assert!(!should_replace_metadata(real, Span::dummy(500, 600), polyfill, Span::dummy(0, 10)));
1725    }
1726
1727    #[test]
1728    fn user_defined_beats_polyfill_flag() {
1729        let polyfill_user = MetadataFlags::POLYFILL | MetadataFlags::USER_DEFINED;
1730        let plain = MetadataFlags::empty();
1731        assert!(!should_replace_metadata(polyfill_user, Span::dummy(0, 10), plain, Span::dummy(0, 10)));
1732        assert!(should_replace_metadata(plain, Span::dummy(0, 10), polyfill_user, Span::dummy(0, 10)));
1733    }
1734
1735    #[test]
1736    fn two_user_defined_fall_through_to_polyfill_check() {
1737        let a = MetadataFlags::POLYFILL | MetadataFlags::USER_DEFINED;
1738        let b = MetadataFlags::USER_DEFINED;
1739        assert!(should_replace_metadata(a, Span::dummy(0, 10), b, Span::dummy(0, 10)));
1740        assert!(!should_replace_metadata(b, Span::dummy(0, 10), a, Span::dummy(0, 10)));
1741    }
1742
1743    #[test]
1744    fn two_non_polyfills_fall_through_to_priority_rules() {
1745        let user = MetadataFlags::USER_DEFINED;
1746        let builtin = MetadataFlags::BUILTIN;
1747        assert!(!should_replace_metadata(user, Span::dummy(0, 10), builtin, Span::dummy(0, 10)));
1748        assert!(should_replace_metadata(builtin, Span::dummy(0, 10), user, Span::dummy(0, 10)));
1749    }
1750
1751    #[test]
1752    fn patch_beats_vendored() {
1753        let vendored = MetadataFlags::empty();
1754        let patch = MetadataFlags::PATCH;
1755        assert!(should_replace_metadata(vendored, Span::dummy(0, 100), patch, Span::dummy(0, 100)));
1756        assert!(!should_replace_metadata(patch, Span::dummy(0, 100), vendored, Span::dummy(0, 100)));
1757    }
1758
1759    #[test]
1760    fn patch_beats_builtin() {
1761        let builtin = MetadataFlags::BUILTIN;
1762        let patch = MetadataFlags::PATCH;
1763        assert!(should_replace_metadata(builtin, Span::dummy(0, 100), patch, Span::dummy(0, 100)));
1764        assert!(!should_replace_metadata(patch, Span::dummy(0, 100), builtin, Span::dummy(0, 100)));
1765    }
1766
1767    #[test]
1768    fn external_beats_builtin_and_vendored() {
1769        let external = MetadataFlags::EXTERNAL;
1770        let builtin = MetadataFlags::BUILTIN;
1771        let vendored = MetadataFlags::empty();
1772
1773        assert!(should_replace_metadata(builtin, Span::dummy(0, 100), external, Span::dummy(500, 600)));
1774        assert!(!should_replace_metadata(external, Span::dummy(500, 600), builtin, Span::dummy(0, 100)));
1775        assert!(should_replace_metadata(vendored, Span::dummy(0, 100), external, Span::dummy(500, 600)));
1776        assert!(!should_replace_metadata(external, Span::dummy(500, 600), vendored, Span::dummy(0, 100)));
1777    }
1778
1779    #[test]
1780    fn patch_and_user_defined_beat_external() {
1781        let external = MetadataFlags::EXTERNAL;
1782        let patch = MetadataFlags::PATCH;
1783        let user = MetadataFlags::USER_DEFINED;
1784
1785        assert!(should_replace_metadata(external, Span::dummy(0, 100), patch, Span::dummy(500, 600)));
1786        assert!(!should_replace_metadata(patch, Span::dummy(500, 600), external, Span::dummy(0, 100)));
1787        assert!(should_replace_metadata(external, Span::dummy(0, 100), user, Span::dummy(500, 600)));
1788        assert!(!should_replace_metadata(user, Span::dummy(500, 600), external, Span::dummy(0, 100)));
1789    }
1790
1791    #[test]
1792    fn user_defined_beats_patch() {
1793        let user = MetadataFlags::USER_DEFINED;
1794        let patch = MetadataFlags::PATCH;
1795        assert!(!should_replace_metadata(user, Span::dummy(0, 100), patch, Span::dummy(0, 100)));
1796        assert!(should_replace_metadata(patch, Span::dummy(0, 100), user, Span::dummy(0, 100)));
1797    }
1798
1799    #[test]
1800    fn patch_does_not_beat_user_defined_even_with_smaller_span() {
1801        let user = MetadataFlags::USER_DEFINED;
1802        let patch = MetadataFlags::PATCH;
1803        assert!(!should_replace_metadata(user, Span::dummy(500, 600), patch, Span::dummy(0, 10)));
1804    }
1805
1806    #[test]
1807    #[allow(clippy::expect_used)]
1808    fn patch_function_like_leaves_vendor_owning_slot() {
1809        // Patches may only refine type information on an existing function-like; they must
1810        // never become the slot owner. The non-patch source's span/file id stay put so that
1811        // `extract_owned_keys` records vendor-as-owner — otherwise the patch's entry would
1812        // outlive a vendor deletion in incremental mode (orphan function-like bug).
1813        use crate::metadata::function_like::FunctionLikeKind;
1814
1815        let name = word("foo");
1816        let key = (empty_word(), name);
1817        let vendor_span = Span::dummy(0, 100);
1818        let patch_span = Span::dummy(500, 600);
1819
1820        let vendor =
1821            FunctionLikeMetadata::new(FunctionLikeKind::Function, name, name, vendor_span, MetadataFlags::empty());
1822        let mut codebase = CodebaseMetadata::new();
1823        codebase.function_likes.insert(key, vendor);
1824
1825        let patch = FunctionLikeMetadata::new(FunctionLikeKind::Function, name, name, patch_span, MetadataFlags::PATCH);
1826        codebase.patch_function_likes.insert(key, patch);
1827
1828        codebase.apply_patches_pass();
1829
1830        let merged = codebase.function_likes.get(&key).expect("function-like must remain after patch");
1831        assert_eq!(merged.span, vendor_span, "patch must not move the slot's span");
1832        assert!(!merged.flags.is_patch(), "patch must not flip the slot's flags");
1833    }
1834
1835    #[test]
1836    fn patch_does_not_apply_to_user_defined_class() {
1837        let class_name = word("MyClass");
1838        let method_existing = word("doIt");
1839
1840        let mut user_class =
1841            ClassLikeMetadata::new(class_name, class_name, Span::dummy(0, 100), None, MetadataFlags::USER_DEFINED);
1842        user_class.methods.insert(method_existing);
1843
1844        let mut codebase = CodebaseMetadata::new();
1845        codebase.class_likes.insert(class_name, user_class);
1846
1847        let mut patch_class =
1848            ClassLikeMetadata::new(class_name, class_name, Span::dummy(0, 50), None, MetadataFlags::PATCH);
1849        let method_new = word("patchedMethod");
1850        patch_class.methods.insert(method_new);
1851
1852        codebase.patch_class_likes.insert(class_name, patch_class);
1853
1854        codebase.apply_patches_pass();
1855
1856        let class = &codebase.class_likes[&class_name];
1857        // Patch must not apply to a user-defined class.
1858        assert!(!class.methods.contains(&method_new));
1859        // User-defined class must be preserved intact.
1860        assert!(class.methods.contains(&method_existing));
1861        assert!(class.flags.is_user_defined());
1862        // No issues should be emitted.
1863        assert!(class.issues.is_empty());
1864    }
1865}