Skip to main content

mago_codex/metadata/
class_like.rs

1use foldhash::fast::RandomState;
2use indexmap::IndexMap;
3use mago_php_version::PHPVersion;
4use mago_php_version::PHPVersionRange;
5
6use mago_reporting::Annotation;
7use mago_reporting::Issue;
8use mago_span::Span;
9use mago_word::Word;
10use mago_word::WordMap;
11use mago_word::WordSet;
12
13use crate::flags::attribute::AttributeFlags;
14use crate::identifier::method::MethodIdentifier;
15use crate::issue::ScanningIssueKind;
16use crate::metadata::attribute::AttributeMetadata;
17use crate::metadata::class_like_constant::ClassLikeConstantMetadata;
18use crate::metadata::enum_case::EnumCaseMetadata;
19use crate::metadata::flags::MetadataFlags;
20use crate::metadata::property::PropertyMetadata;
21use crate::metadata::ttype::TypeMetadata;
22use crate::metadata::version_constraint::VersionConstraint;
23use crate::symbol::SymbolKind;
24use crate::ttype::atomic::TAtomic;
25use crate::ttype::template::GenericTemplate;
26use crate::ttype::template::variance::Variance;
27use crate::ttype::union::TUnion;
28use crate::visibility::Visibility;
29
30/// Type alias for template types stored in metadata.
31/// Maps template parameter names to their defining entity and constraint type.
32pub type TemplateTypes = IndexMap<Word, GenericTemplate, RandomState>;
33
34/// Contains comprehensive metadata for a PHP class-like structure (class, interface, trait, enum).
35///
36/// Aggregates information about inheritance, traits, generics, methods, properties, constants,
37/// attributes, docblock tags, analysis flags, and more.
38#[derive(Clone, Debug, PartialEq, Eq)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40#[non_exhaustive]
41pub struct ClassLikeMetadata {
42    pub name: Word,
43    pub original_name: Word,
44    #[cfg_attr(feature = "serde", serde(default))]
45    pub aliases: Vec<Word>,
46    pub span: Span,
47    pub direct_parent_interfaces: WordSet,
48    pub all_parent_interfaces: WordSet,
49    pub direct_parent_class: Option<Word>,
50    pub require_extends: WordSet,
51    pub require_implements: WordSet,
52    pub all_parent_classes: WordSet,
53    pub used_traits: WordSet,
54    pub trait_alias_map: WordMap<Word>,
55    pub trait_visibility_map: WordMap<Visibility>,
56    pub trait_final_map: WordSet,
57    pub child_class_likes: Option<WordSet>,
58    pub name_span: Option<Span>,
59    pub kind: SymbolKind,
60    pub template_types: TemplateTypes,
61    pub template_readonly: WordSet,
62    pub template_variance: Vec<Variance>,
63    pub template_extended_offsets: WordMap<Vec<TUnion>>,
64    pub template_extended_parameters: WordMap<IndexMap<Word, TUnion, RandomState>>,
65    pub template_extended_parameter_paths: WordMap<Vec<IndexMap<Word, TUnion, RandomState>>>,
66    pub template_type_extends_count: WordMap<usize>,
67    pub template_type_implements_count: WordMap<usize>,
68    pub template_type_uses_count: WordMap<usize>,
69    pub methods: WordSet,
70    pub pseudo_methods: WordSet,
71    pub static_pseudo_methods: WordSet,
72    pub declaring_method_ids: WordMap<MethodIdentifier>,
73    pub appearing_method_ids: WordMap<MethodIdentifier>,
74    pub inheritable_method_ids: WordMap<MethodIdentifier>,
75    pub overridden_method_ids: WordMap<IndexMap<Word, MethodIdentifier, RandomState>>,
76    pub properties: WordMap<PropertyMetadata>,
77    /// Magic properties documented via `@property`/`@property-read`/`@property-write` in this
78    /// class-like's own docblock.
79    ///
80    /// These describe the external `__get`/`__set` interface and are kept separate from the
81    /// real declarations in `properties`; which of the two governs a given access is decided
82    /// at resolution time from the call site's scope.  Inherited tags are reachable through
83    /// `magic_property_ids`, mirroring how `declaring_property_ids` works for real properties.
84    pub magic_properties: WordMap<PropertyMetadata>,
85    /// Maps every magic property name reachable on this class-like (own or inherited) to the
86    /// class-like whose docblock declares the tag.  An own tag beats inherited ones; between
87    /// tags inherited from unrelated parents, the first-populated parent wins (as with real
88    /// property inheritance).
89    pub magic_property_ids: WordMap<Word>,
90    pub appearing_property_ids: WordMap<Word>,
91    pub declaring_property_ids: WordMap<Word>,
92    pub inheritable_property_ids: WordMap<Word>,
93    pub overridden_property_ids: WordMap<WordSet>,
94    pub initialized_properties: WordSet,
95    pub constants: WordMap<ClassLikeConstantMetadata>,
96    pub trait_constant_ids: WordMap<Word>,
97    pub enum_cases: WordMap<EnumCaseMetadata>,
98    pub invalid_dependencies: WordSet,
99    pub attributes: Vec<AttributeMetadata>,
100    pub enum_type: Option<TAtomic>,
101    pub has_sealed_methods: Option<bool>,
102    pub has_sealed_properties: Option<bool>,
103    pub permitted_inheritors: Option<WordSet>,
104    pub issues: Vec<Issue>,
105    pub attribute_flags: Option<AttributeFlags>,
106    pub flags: MetadataFlags,
107    pub type_aliases: WordMap<TypeMetadata>,
108    /// Imported type aliases in the form of (`from_fqcn`, `type_name`, span)
109    pub imported_type_aliases: WordMap<(Word, Word, Span)>,
110    /// Mixin types from @mixin annotations - these types' methods/properties
111    /// can be accessed via magic methods (__call, __get, __set, __callStatic)
112    pub mixins: Vec<TypeMetadata>,
113    pub version_constraint: VersionConstraint,
114}
115
116impl ClassLikeMetadata {
117    /// Returns whether this class-like's inherited metadata is incomplete.
118    ///
119    /// Synthetic enum contracts are materialized by the scanner and do not make
120    /// an enum incomplete when their own metadata is unavailable.
121    #[inline]
122    #[must_use]
123    pub fn has_incomplete_hierarchy(&self) -> bool {
124        self.incomplete_hierarchy_dependencies().next().is_some()
125    }
126
127    /// Returns the unresolved dependencies that make this class-like's
128    /// inherited metadata incomplete.
129    ///
130    /// Synthetic enum contracts are materialized by the scanner and are not
131    /// exposed as unresolved hierarchy dependencies.
132    #[inline]
133    pub fn incomplete_hierarchy_dependencies(&self) -> impl Iterator<Item = Word> + '_ {
134        let is_enum = self.kind.is_enum();
135        self.invalid_dependencies.iter().copied().filter(move |dependency| {
136            !is_enum
137                || !matches!(
138                    dependency.as_bytes(),
139                    b"unitenum"
140                        | b"backedenum"
141                        | b"__internal_do_not_use__intbackedenum"
142                        | b"__internal_do_not_use__stringbackedenum"
143                )
144        })
145    }
146
147    #[must_use]
148    pub fn new(
149        name: Word,
150        original_name: Word,
151        span: Span,
152        name_span: Option<Span>,
153        flags: MetadataFlags,
154    ) -> ClassLikeMetadata {
155        ClassLikeMetadata {
156            constants: WordMap::default(),
157            trait_constant_ids: WordMap::default(),
158            enum_cases: WordMap::default(),
159            flags,
160            kind: SymbolKind::Class,
161            direct_parent_interfaces: WordSet::default(),
162            all_parent_classes: WordSet::default(),
163            appearing_method_ids: WordMap::default(),
164            attributes: Vec::new(),
165            all_parent_interfaces: WordSet::default(),
166            declaring_method_ids: WordMap::default(),
167            appearing_property_ids: WordMap::default(),
168            declaring_property_ids: WordMap::default(),
169            direct_parent_class: None,
170            require_extends: WordSet::default(),
171            require_implements: WordSet::default(),
172            inheritable_method_ids: WordMap::default(),
173            enum_type: None,
174            inheritable_property_ids: WordMap::default(),
175            initialized_properties: WordSet::default(),
176            invalid_dependencies: WordSet::default(),
177            span,
178            name_span,
179            methods: WordSet::default(),
180            pseudo_methods: WordSet::default(),
181            static_pseudo_methods: WordSet::default(),
182            overridden_method_ids: WordMap::default(),
183            overridden_property_ids: WordMap::default(),
184            properties: WordMap::default(),
185            magic_properties: WordMap::default(),
186            magic_property_ids: WordMap::default(),
187            template_variance: Vec::new(),
188            template_type_extends_count: WordMap::default(),
189            template_extended_parameters: WordMap::default(),
190            template_extended_parameter_paths: WordMap::default(),
191            template_extended_offsets: WordMap::default(),
192            template_type_implements_count: WordMap::default(),
193            template_type_uses_count: WordMap::default(),
194            template_types: TemplateTypes::default(),
195            used_traits: WordSet::default(),
196            trait_alias_map: WordMap::default(),
197            trait_visibility_map: WordMap::default(),
198            trait_final_map: WordSet::default(),
199            name,
200            original_name,
201            aliases: Vec::new(),
202            child_class_likes: None,
203            template_readonly: WordSet::default(),
204            has_sealed_methods: None,
205            has_sealed_properties: None,
206            permitted_inheritors: None,
207            issues: vec![],
208            attribute_flags: None,
209            type_aliases: WordMap::default(),
210            imported_type_aliases: WordMap::default(),
211            mixins: Vec::default(),
212            version_constraint: VersionConstraint::unconstrained(),
213        }
214    }
215
216    /// Returns `true` when this class-like is available in the given PHP
217    /// version.
218    #[inline]
219    #[must_use]
220    pub fn is_available_in_version(&self, version: PHPVersion) -> bool {
221        self.version_constraint.allows_version(version)
222    }
223
224    /// Returns `true` when this class-like is available across the entire
225    /// supplied [`PHPVersionRange`].
226    #[inline]
227    #[must_use]
228    pub fn is_available_in_version_range(&self, range: PHPVersionRange) -> bool {
229        self.version_constraint.allows_version_range(range)
230    }
231
232    /// Returns a reference to the map of trait method aliases.
233    #[inline]
234    #[must_use]
235    pub fn get_trait_alias_map(&self) -> &WordMap<Word> {
236        &self.trait_alias_map
237    }
238
239    /// Returns a vector of the generic type parameter names.
240    #[inline]
241    #[must_use]
242    pub fn get_template_type_names(&self) -> Vec<Word> {
243        self.template_types.keys().copied().collect()
244    }
245
246    /// Returns type parameters for a specific generic parameter name.
247    #[inline]
248    #[must_use]
249    pub fn get_template_type(&self, name: Word) -> Option<&GenericTemplate> {
250        self.template_types.get(&name)
251    }
252
253    #[must_use]
254    pub fn get_template_name_for_index(&self, index: usize) -> Option<Word> {
255        self.template_types.get_index(index).map(|(name, _)| *name)
256    }
257
258    #[must_use]
259    pub fn get_template_index_for_name(&self, name: Word) -> Option<usize> {
260        self.template_types.get_index_of(&name)
261    }
262
263    /// Checks if a specific parent is either a parent class or interface.
264    #[inline]
265    #[must_use]
266    pub fn has_parent(&self, parent: Word) -> bool {
267        self.all_parent_classes.contains(&parent) || self.all_parent_interfaces.contains(&parent)
268    }
269
270    /// Checks if a specific method appears in this class-like.
271    #[inline]
272    #[must_use]
273    pub fn has_appearing_method(&self, method: Word) -> bool {
274        self.appearing_method_ids.contains_key(&method)
275    }
276
277    /// Returns a vector of property names.
278    #[inline]
279    #[must_use]
280    pub fn get_property_names(&self) -> WordSet {
281        self.properties.keys().copied().collect()
282    }
283
284    /// Checks if a specific property appears in this class-like.
285    #[inline]
286    #[must_use]
287    pub fn has_appearing_property(&self, name: Word) -> bool {
288        self.appearing_property_ids.contains_key(&name)
289    }
290
291    /// Takes ownership of the issues found for this class-like structure.
292    #[inline]
293    pub fn take_issues(&mut self) -> Vec<Issue> {
294        std::mem::take(&mut self.issues)
295    }
296
297    /// Adds a single direct parent interface.
298    #[inline]
299    pub fn add_direct_parent_interface(&mut self, interface: Word) {
300        self.direct_parent_interfaces.insert(interface);
301        self.all_parent_interfaces.insert(interface);
302    }
303
304    /// Adds a single used trait. Returns `true` if the trait was not already present.
305    #[inline]
306    pub fn add_used_trait(&mut self, trait_name: Word) -> bool {
307        self.used_traits.insert(trait_name)
308    }
309
310    /// Adds multiple used traits.
311    #[inline]
312    pub fn add_used_traits(&mut self, traits: impl IntoIterator<Item = Word>) {
313        self.used_traits.extend(traits);
314    }
315
316    /// Adds or updates a single trait alias. Returns the previous original name if one existed for the alias.
317    #[inline]
318    pub fn add_trait_alias(&mut self, method: Word, alias: Word) -> Option<Word> {
319        self.trait_alias_map.insert(method, alias)
320    }
321
322    /// Adds or updates a single trait visibility override. Returns the previous visibility if one existed.
323    #[inline]
324    pub fn add_trait_visibility(&mut self, method: Word, visibility: Visibility) -> Option<Visibility> {
325        self.trait_visibility_map.insert(method, visibility)
326    }
327
328    /// Adds a single template type definition.
329    #[inline]
330    pub fn add_template_type(&mut self, name: Word, constraint: GenericTemplate) {
331        self.template_types.insert(name, constraint);
332    }
333
334    /// Set the variance for the template parameters
335    #[inline]
336    pub fn set_template_variance(&mut self, template_variance: Vec<Variance>) {
337        self.template_variance = template_variance;
338    }
339
340    /// Adds or replaces the offset types for a specific template parameter name.
341    #[inline]
342    pub fn add_template_extended_offset(&mut self, name: Word, types: Vec<TUnion>) -> Option<Vec<TUnion>> {
343        self.template_extended_offsets.insert(name, types)
344    }
345
346    /// Adds or replaces the resolved parameters for a specific parent FQCN.
347    #[inline]
348    pub fn extend_template_extended_parameters(
349        &mut self,
350        template_extended_parameters: WordMap<IndexMap<Word, TUnion, RandomState>>,
351    ) {
352        self.template_extended_parameters.extend(template_extended_parameters);
353    }
354
355    /// Adds or replaces a single resolved parameter for the parent FQCN.
356    #[inline]
357    pub fn add_template_extended_parameter(
358        &mut self,
359        parent_fqcn: Word,
360        parameter_name: Word,
361        parameter_type: TUnion,
362    ) -> Option<TUnion> {
363        self.template_extended_parameters.entry(parent_fqcn).or_default().insert(parameter_name, parameter_type)
364    }
365
366    /// Records one complete parameterization of `ancestor` (a single inheritance
367    /// path), de-duplicating against parameterizations already recorded.
368    #[inline]
369    pub fn record_template_extended_path(&mut self, ancestor: Word, parameters: IndexMap<Word, TUnion, RandomState>) {
370        if parameters.is_empty() {
371            return;
372        }
373
374        let paths = self.template_extended_parameter_paths.entry(ancestor).or_default();
375        if !paths.contains(&parameters) {
376            paths.push(parameters);
377        }
378    }
379
380    /// Adds or updates the declaring method identifier for a method name.
381    #[inline]
382    pub fn add_declaring_method_id(
383        &mut self,
384        method: Word,
385        declaring_method_id: MethodIdentifier,
386    ) -> Option<MethodIdentifier> {
387        self.add_appearing_method_id(method, declaring_method_id);
388        self.declaring_method_ids.insert(method, declaring_method_id)
389    }
390
391    /// Adds or updates the appearing method identifier for a method name.
392    #[inline]
393    pub fn add_appearing_method_id(
394        &mut self,
395        method: Word,
396        appearing_method_id: MethodIdentifier,
397    ) -> Option<MethodIdentifier> {
398        self.appearing_method_ids.insert(method, appearing_method_id)
399    }
400
401    /// Adds a parent method identifier to the map for an overridden method. Initializes map if needed. Returns the previous value if one existed.
402    #[inline]
403    pub fn add_overridden_method_parent(
404        &mut self,
405        method: Word,
406        parent_method_id: MethodIdentifier,
407    ) -> Option<MethodIdentifier> {
408        self.overridden_method_ids
409            .entry(method)
410            .or_default()
411            .insert(parent_method_id.get_class_name(), parent_method_id)
412    }
413
414    /// Adds or updates a property's metadata. Returns the previous metadata if the property existed.
415    #[inline]
416    pub fn add_property(&mut self, name: Word, property_metadata: PropertyMetadata) -> Option<PropertyMetadata> {
417        let class_name = self.name;
418
419        self.add_declaring_property_id(name, class_name);
420        if property_metadata.flags.has_default() {
421            self.initialized_properties.insert(name);
422        }
423
424        if !property_metadata.is_final() {
425            self.inheritable_property_ids.insert(name, class_name);
426        }
427
428        self.properties.insert(name, property_metadata)
429    }
430
431    /// Adds or updates a property's metadata using just the property metadata. Returns the previous metadata if the property existed.
432    #[inline]
433    pub fn add_property_metadata(&mut self, property_metadata: PropertyMetadata) -> Option<PropertyMetadata> {
434        let name = property_metadata.get_name().0;
435
436        self.add_property(name, property_metadata)
437    }
438
439    /// Adds or updates the declaring class FQCN for a property name.
440    #[inline]
441    pub fn add_declaring_property_id(&mut self, prop: Word, declaring_fqcn: Word) -> Option<Word> {
442        self.appearing_property_ids.insert(prop, declaring_fqcn);
443        self.declaring_property_ids.insert(prop, declaring_fqcn)
444    }
445
446    #[must_use]
447    pub fn get_missing_required_interface<'meta>(&self, other: &'meta ClassLikeMetadata) -> Option<&'meta Word> {
448        for required_interface in &other.require_implements {
449            if self.all_parent_interfaces.contains(required_interface) {
450                continue;
451            }
452
453            if (self.flags.is_abstract() || self.kind.is_trait())
454                && self.require_implements.contains(required_interface)
455            {
456                continue; // Abstract classes and traits can require interfaces they implement
457            }
458
459            return Some(required_interface);
460        }
461
462        None
463    }
464
465    #[must_use]
466    pub fn get_missing_required_extends<'meta>(&self, other: &'meta ClassLikeMetadata) -> Option<&'meta Word> {
467        for required_extend in &other.require_extends {
468            if self.name == *required_extend {
469                continue;
470            }
471
472            if self.all_parent_classes.contains(required_extend) {
473                continue;
474            }
475
476            if self.kind.is_interface() && self.all_parent_interfaces.contains(required_extend) {
477                continue;
478            }
479
480            if (self.flags.is_abstract() || self.kind.is_trait()) && self.require_extends.contains(required_extend) {
481                continue; // Abstract classes and traits can require classes they extend
482            }
483
484            return Some(required_extend);
485        }
486
487        None
488    }
489
490    #[must_use]
491    pub fn is_permitted_to_inherit(&self, other: &ClassLikeMetadata) -> bool {
492        if self.kind.is_trait() || self.flags.is_abstract() {
493            return true; // Traits and abstract classes can always inherit
494        }
495
496        let Some(permitted_inheritors) = &other.permitted_inheritors else {
497            return true; // No restrictions, inheriting is allowed
498        };
499
500        if permitted_inheritors.contains(&self.name) {
501            return true; // This class-like is explicitly permitted to inherit
502        }
503
504        self.all_parent_interfaces.iter().any(|parent_interface| permitted_inheritors.contains(parent_interface))
505            || self.all_parent_classes.iter().any(|parent_class| permitted_inheritors.contains(parent_class))
506            || self.used_traits.iter().any(|used_trait| permitted_inheritors.contains(used_trait))
507    }
508
509    #[inline]
510    pub fn mark_as_populated(&mut self) {
511        self.flags |= MetadataFlags::POPULATED;
512        self.shrink_to_fit();
513    }
514
515    /// Applies a patch to this class in place, refining member type information while leaving
516    /// structure (hierarchy, override chains, initialization state, enum cases) untouched.
517    ///
518    /// At most one patch targets a given symbol, so there is no accumulation to worry about.
519    /// Diagnostics about the patch are appended to `patch.issues`, where scan-time diagnostics
520    /// also live, so they flow out through `take_issues`. The full set of rules a patch must
521    /// obey is documented in the `[source]` patching guide.
522    pub fn apply_patch(&mut self, patch: &mut ClassLikeMetadata, inherited_methods: &WordSet) {
523        // Patches redeclare the same class by name, so their member identifiers
524        // already carry the correct class name and merge cleanly.
525        debug_assert_eq!(self.name, patch.name, "patch class name must match the patched class");
526
527        // A kind or hierarchy mismatch means the patch targets a different symbol; reject
528        // it wholesale rather than refining members against the wrong class.
529        if self.report_kind_mismatch(patch) || self.report_hierarchy_mismatch(patch) {
530            return;
531        }
532
533        self.report_readonly_mismatch(patch);
534        self.report_trait_usage(patch);
535
536        self.patch_templates(patch);
537        self.patch_methods(patch, inherited_methods);
538        self.patch_properties(patch);
539        self.patch_constants(patch);
540        self.report_enum_cases(patch);
541
542        // Type aliases
543        self.type_aliases.extend(patch.type_aliases.iter().map(|(k, v)| (*k, v.clone())));
544    }
545
546    /// Reports a `kind` mismatch between the patch and the original symbol.
547    ///
548    /// Returns `true` when the kinds differ, in which case the patch must be rejected
549    /// wholesale because it targets a different kind of symbol.
550    fn report_kind_mismatch(&self, patch: &mut ClassLikeMetadata) -> bool {
551        if self.kind == patch.kind {
552            return false;
553        }
554
555        patch.issues.push(
556            Issue::error(format!(
557                "Patch declares `{}` as a {} but the original symbol is a {}; patch members are ignored.",
558                patch.original_name,
559                patch.kind.as_str(),
560                self.kind.as_str(),
561            ))
562            .with_code(ScanningIssueKind::PatchKindMismatch)
563            .with_annotation(Annotation::primary(patch.span))
564            .with_help(format!(
565                "Declare `{}` as a {} in the patch so it matches the original symbol, \
566                 or remove the patch if it targets the wrong symbol.",
567                patch.original_name,
568                self.kind.as_str(),
569            )),
570        );
571
572        true
573    }
574
575    /// Reports a hierarchy mismatch between the patch and the original symbol.
576    ///
577    /// Hierarchy declarations must match the original exactly if declared; a mismatch means
578    /// the patch is describing a different class. Returns `true` when they differ, in which
579    /// case the patch must be rejected wholesale.
580    fn report_hierarchy_mismatch(&self, patch: &mut ClassLikeMetadata) -> bool {
581        let hierarchy_mismatch = (patch.direct_parent_class.is_some()
582            && patch.direct_parent_class != self.direct_parent_class)
583            || (!patch.direct_parent_interfaces.is_empty()
584                && patch.direct_parent_interfaces != self.direct_parent_interfaces)
585            || (!patch.require_extends.is_empty() && patch.require_extends != self.require_extends)
586            || (!patch.require_implements.is_empty() && patch.require_implements != self.require_implements);
587
588        if !hierarchy_mismatch {
589            return false;
590        }
591
592        patch.issues.push(
593            Issue::error(format!(
594                "Patch for `{}` declares hierarchy that does not match the original; patch members are ignored.",
595                patch.original_name,
596            ))
597            .with_code(ScanningIssueKind::PatchHierarchyMismatch)
598            .with_annotation(Annotation::primary(patch.span))
599            .with_help(
600                "Patches do not need to restate hierarchy. Drop the parent class, interface, \
601                 `@require-extends`, or `@require-implements` declarations from the patch, or \
602                 correct them to match the original exactly.",
603            ),
604        );
605
606        true
607    }
608
609    /// Reports a `readonly class` modifier mismatch.
610    ///
611    /// `readonly class` is a structural modifier — a patch cannot add or remove it. This is a
612    /// warning only and does not abort the patch.
613    fn report_readonly_mismatch(&self, patch: &mut ClassLikeMetadata) {
614        if patch.flags.contains(MetadataFlags::READONLY) == self.flags.contains(MetadataFlags::READONLY) {
615            return;
616        }
617
618        patch.issues.push(
619            Issue::warning(format!(
620                "Patch declares `{}` as a {} class but the original is {}; readonly modifier is ignored.",
621                patch.original_name,
622                if patch.flags.contains(MetadataFlags::READONLY) { "readonly" } else { "non-readonly" },
623                if self.flags.contains(MetadataFlags::READONLY) { "readonly" } else { "non-readonly" },
624            ))
625            .with_code(ScanningIssueKind::PatchReadonlyMismatch)
626            .with_annotation(Annotation::primary(patch.span))
627            .with_help(
628                "The `readonly` class modifier is structural and cannot be changed by a patch. \
629                 Match the original by adding or removing `readonly` on the patched class declaration.",
630            ),
631        );
632    }
633
634    /// Reports `use` trait declarations on the patch, which are never valid.
635    ///
636    /// Patches refine member type information only; trait usage declarations are ignored.
637    fn report_trait_usage(&self, patch: &mut ClassLikeMetadata) {
638        if patch.used_traits.is_empty() {
639            return;
640        }
641
642        patch.issues.push(
643            Issue::warning(format!(
644                "Patch for `{}` declares `use` traits; patches refine member type information only and trait usage declarations are ignored.",
645                patch.original_name,
646            ))
647            .with_code(ScanningIssueKind::PatchDeclaresTrait)
648            .with_annotation(Annotation::primary(patch.span))
649            .with_help(
650                "Remove the `use` trait statement from the patch. To refine the type information \
651                 of members the trait contributes, patch those members on the class directly.",
652            ),
653        );
654    }
655
656    /// Merges `@template` declarations from the patch: existing names are overridden, new
657    /// names appended.
658    ///
659    /// `template_variance` is keyed by position in the `template_types` IndexMap and must be
660    /// rebuilt after the merge; `template_readonly` is name-keyed and can be extended directly.
661    fn patch_templates(&mut self, patch: &ClassLikeMetadata) {
662        if patch.template_types.is_empty() {
663            return;
664        }
665
666        // Collect name → variance for the current (original) state.
667        let mut name_to_variance: WordMap<Variance> = self
668            .template_types
669            .keys()
670            .enumerate()
671            .map(|(i, name)| (*name, self.template_variance.get(i).copied().unwrap_or(Variance::Invariant)))
672            .collect();
673
674        // Patch overrides existing entries and contributes new ones.
675        name_to_variance.extend(
676            patch
677                .template_types
678                .keys()
679                .enumerate()
680                .map(|(i, name)| (*name, patch.template_variance.get(i).copied().unwrap_or(Variance::Invariant))),
681        );
682
683        // Extend the IndexMap: existing names get updated definitions, new ones are appended.
684        self.template_types.extend(patch.template_types.iter().map(|(k, v)| (*k, v.clone())));
685
686        // Rebuild position-indexed variance vec to match the merged IndexMap order.
687        // template_readonly is name-keyed so it doesn't need rebuilding — just extend it.
688        self.template_variance = self
689            .template_types
690            .keys()
691            .map(|name| name_to_variance.get(name).copied().unwrap_or(Variance::Invariant))
692            .collect();
693        self.template_readonly.extend(patch.template_readonly.iter().copied());
694    }
695
696    /// Applies method declarations from the patch.
697    ///
698    /// A real method already declared on the original needs no change here — its type info
699    /// flows through `FunctionLikeMetadata::apply_patch` on the function-like. A method
700    /// inherited from an ancestor is added to the structural maps so the populator treats this
701    /// class as the declaring site, and `apply_patches_pass` then materializes the
702    /// function-like. A method that exists nowhere in the chain is rejected as a new member.
703    /// Pseudo-methods (`@method`) are class-level and always update the structural maps.
704    fn patch_methods(&mut self, patch: &mut ClassLikeMetadata, inherited_methods: &WordSet) {
705        for method_name in &patch.methods {
706            if patch.pseudo_methods.contains(method_name) || patch.static_pseudo_methods.contains(method_name) {
707                continue;
708            }
709            if inherited_methods.contains(method_name) {
710                // The patch introduces an override for an inherited method. Mirror what the
711                // scanner records for a directly-declared method so the populator preserves
712                // this class as the declaring/appearing site (rather than re-inheriting the
713                // ancestor's ids) and the refined function-like at `(self, method)` wins.
714                // `apply_patches_pass` materializes that function-like from the ancestor.
715                if self.methods.insert(*method_name) {
716                    if let Some(id) = patch.declaring_method_ids.get(method_name) {
717                        self.declaring_method_ids.insert(*method_name, *id);
718                    }
719                    if let Some(id) = patch.appearing_method_ids.get(method_name) {
720                        self.appearing_method_ids.insert(*method_name, *id);
721                    }
722                    if let Some(id) = patch.inheritable_method_ids.get(method_name) {
723                        self.inheritable_method_ids.insert(*method_name, *id);
724                    }
725                }
726            } else if !self.methods.contains(method_name) {
727                patch.issues.push(
728                    Issue::error(format!(
729                        "Patch for `{}` declares method `{}` which does not exist in the original \
730                         or any of its ancestors; patches cannot introduce new methods.",
731                        patch.original_name, method_name,
732                    ))
733                    .with_code(ScanningIssueKind::PatchIntroducesNewMethod)
734                    .with_annotation(Annotation::primary(patch.span))
735                    .with_help(format!(
736                        "Remove `{method_name}` from the patch, or correct its name to match a method \
737                         that already exists on `{}` or one of its ancestors. To annotate a magic \
738                         method handled by `__call`/`__callStatic`, declare it with `@method` instead. \
739                         Patches can only refine the types of existing methods.",
740                        patch.original_name,
741                    )),
742                );
743            }
744        }
745
746        for name in patch.pseudo_methods.iter().chain(patch.static_pseudo_methods.iter()) {
747            if let Some(id) = patch.declaring_method_ids.get(name) {
748                self.declaring_method_ids.insert(*name, *id);
749            }
750            if let Some(id) = patch.appearing_method_ids.get(name) {
751                self.appearing_method_ids.insert(*name, *id);
752            }
753            if let Some(id) = patch.inheritable_method_ids.get(name) {
754                self.inheritable_method_ids.insert(*name, *id);
755            }
756        }
757
758        self.pseudo_methods.extend(patch.pseudo_methods.iter().copied());
759        self.static_pseudo_methods.extend(patch.static_pseudo_methods.iter().copied());
760    }
761
762    /// Refines type annotations on existing properties. New properties are rejected unless
763    /// they are magic properties (`@property`/`@property-read`/`@property-write`), which carry
764    /// no runtime existence claim. Structural attributes must match the original.
765    fn patch_properties(&mut self, patch: &mut ClassLikeMetadata) {
766        let patch_span = patch.span;
767        let patch_name = patch.original_name;
768        for (name, prop_metadata) in &patch.properties {
769            if let Some(slot) = self.properties.get_mut(name) {
770                // Patches can only refine type annotations. Structural attributes
771                // (visibility, modifiers, hooks) must match the original exactly;
772                // if they differ the patch is wrong and we should say so rather than
773                // silently discarding the mismatch.
774                let visibility_mismatch = prop_metadata.read_visibility != slot.read_visibility
775                    || prop_metadata.write_visibility != slot.write_visibility;
776                // READONLY, STATIC, ABSTRACT: any mismatch is structural.
777                // FINAL: only an error when removed (vendor has it, patch doesn't);
778                //        adding final via a patch is allowed.
779                let structural_flag_mismatch =
780                    [MetadataFlags::READONLY, MetadataFlags::STATIC, MetadataFlags::ABSTRACT]
781                        .iter()
782                        .any(|&f| prop_metadata.flags.contains(f) != slot.flags.contains(f))
783                        || (slot.flags.contains(MetadataFlags::FINAL)
784                            && !prop_metadata.flags.contains(MetadataFlags::FINAL));
785                let has_hooks = !prop_metadata.hooks.is_empty();
786
787                if visibility_mismatch || structural_flag_mismatch || has_hooks {
788                    patch.issues.push(
789                        Issue::error(format!(
790                            "Patch for `{}::{}` declares structural attributes (visibility, modifiers, \
791                             or hooks) that differ from the original; only type annotations are applied.",
792                            patch_name, name,
793                        ))
794                        .with_code(ScanningIssueKind::PatchPropertyStructuralMismatch)
795                        .with_annotation(Annotation::primary(prop_metadata.span.unwrap_or(patch_span)))
796                        .with_help(format!(
797                            "Declare `{patch_name}::{name}` with the same visibility and modifiers as \
798                             the original and drop any property hooks; a patch may only refine the \
799                             property's type.",
800                        )),
801                    );
802                }
803
804                slot.type_declaration_metadata.clone_from(&prop_metadata.type_declaration_metadata);
805                slot.type_metadata.clone_from(&prop_metadata.type_metadata);
806                slot.write_type_metadata.clone_from(&prop_metadata.write_type_metadata);
807            } else {
808                patch.issues.push(
809                    Issue::error(format!(
810                        "Patch declares property `{}::{}` which does not exist in the original; \
811                         patches cannot introduce new properties.",
812                        patch_name, name,
813                    ))
814                    .with_code(ScanningIssueKind::PatchIntroducesNewProperty)
815                    .with_annotation(Annotation::primary(patch_span))
816                    .with_help(format!(
817                        "Remove `{patch_name}::{name}` from the patch, or correct its name to match an \
818                         existing property. To annotate a magic property handled by `__get`/`__set`, \
819                         declare it with `@property`, `@property-read`, or `@property-write` instead.",
820                    )),
821                );
822            }
823        }
824
825        // Magic `@property*` annotations carry no runtime existence claim: a patch may both
826        // refine an existing annotation and introduce a new one.
827        for (name, prop_metadata) in &patch.magic_properties {
828            self.magic_properties.insert(*name, prop_metadata.clone());
829            self.magic_property_ids.insert(*name, self.name);
830        }
831    }
832
833    /// Refines type annotations on existing constants; new constants are rejected and
834    /// structural attributes must match the original.
835    fn patch_constants(&mut self, patch: &mut ClassLikeMetadata) {
836        let patch_span = patch.span;
837        let patch_name = patch.original_name;
838        for (name, const_metadata) in &patch.constants {
839            if let Some(slot) = self.constants.get_mut(name) {
840                let visibility_mismatch = const_metadata.visibility != slot.visibility;
841                // ABSTRACT: any mismatch is structural.
842                // FINAL: only an error when removed (vendor has it, patch doesn't);
843                //        adding final via a patch is allowed.
844                let structural_flag_mismatch = const_metadata.flags.contains(MetadataFlags::ABSTRACT)
845                    != slot.flags.contains(MetadataFlags::ABSTRACT)
846                    || (slot.flags.contains(MetadataFlags::FINAL)
847                        && !const_metadata.flags.contains(MetadataFlags::FINAL));
848
849                if visibility_mismatch || structural_flag_mismatch {
850                    patch.issues.push(
851                        Issue::error(format!(
852                            "Patch for `{}::{}` declares structural attributes (visibility or modifiers) \
853                             that differ from the original; only type annotations are applied.",
854                            patch_name, name,
855                        ))
856                        .with_code(ScanningIssueKind::PatchConstantStructuralMismatch)
857                        .with_annotation(Annotation::primary(const_metadata.span))
858                        .with_help(format!(
859                            "Declare `{patch_name}::{name}` with the same visibility and modifiers as \
860                             the original; a patch may only refine the constant's type.",
861                        )),
862                    );
863                }
864
865                slot.type_declaration.clone_from(&const_metadata.type_declaration);
866                slot.type_metadata.clone_from(&const_metadata.type_metadata);
867            } else {
868                patch.issues.push(
869                    Issue::error(format!(
870                        "Patch declares constant `{}::{}` which does not exist in the original; \
871                         patches cannot introduce new constants.",
872                        patch_name, name,
873                    ))
874                    .with_code(ScanningIssueKind::PatchIntroducesNewConstant)
875                    .with_annotation(Annotation::primary(patch_span))
876                    .with_help(format!(
877                        "Remove `{patch_name}::{name}` from the patch, or correct its name to match a \
878                         constant that already exists on the original. Patches can only refine the \
879                         types of existing constants.",
880                    )),
881                );
882            }
883        }
884    }
885
886    /// Reports enum case declarations on the patch, which are never valid.
887    ///
888    /// Enum cases are structural (they define the valid runtime values of an enum)
889    /// and cannot be modified by a patch.
890    fn report_enum_cases(&self, patch: &mut ClassLikeMetadata) {
891        if patch.enum_cases.is_empty() {
892            return;
893        }
894
895        patch.issues.push(
896            Issue::error(format!(
897                "Patch for `{}` declares enum case(s); enum cases are structural and cannot be \
898                 refined — patch enum cases are ignored.",
899                patch.original_name,
900            ))
901            .with_code(ScanningIssueKind::PatchEnumCasesIgnored)
902            .with_annotation(Annotation::primary(patch.span))
903            .with_help(
904                "Remove the enum case declarations from the patch. Enum cases define the runtime \
905                 values of the enum and must stay in the original definition; a patch can only \
906                 refine the types of existing members.",
907            ),
908        );
909    }
910
911    #[inline]
912    pub fn shrink_to_fit(&mut self) {
913        self.aliases.shrink_to_fit();
914        self.properties.shrink_to_fit();
915        self.magic_properties.shrink_to_fit();
916        self.magic_property_ids.shrink_to_fit();
917        self.initialized_properties.shrink_to_fit();
918        self.appearing_property_ids.shrink_to_fit();
919        self.declaring_property_ids.shrink_to_fit();
920        self.inheritable_property_ids.shrink_to_fit();
921        self.overridden_property_ids.shrink_to_fit();
922        self.appearing_method_ids.shrink_to_fit();
923        self.declaring_method_ids.shrink_to_fit();
924        self.inheritable_method_ids.shrink_to_fit();
925        self.overridden_method_ids.shrink_to_fit();
926        self.attributes.shrink_to_fit();
927        self.constants.shrink_to_fit();
928        self.enum_cases.shrink_to_fit();
929        self.type_aliases.shrink_to_fit();
930    }
931}
932
933/// Collects all method names reachable through the ancestors of `class_meta`.
934///
935/// Does not include methods defined directly on `class_meta` itself.
936#[must_use]
937pub fn collect_ancestor_methods(class_meta: &ClassLikeMetadata, class_likes: &WordMap<ClassLikeMetadata>) -> WordSet {
938    let mut visited = WordSet::default();
939    let mut methods = WordSet::default();
940    collect_ancestor_methods_inner(class_meta, class_likes, &mut visited, &mut methods);
941    methods
942}
943
944fn collect_ancestor_methods_inner(
945    class_meta: &ClassLikeMetadata,
946    class_likes: &WordMap<ClassLikeMetadata>,
947    visited: &mut WordSet,
948    methods: &mut WordSet,
949) {
950    if !visited.insert(class_meta.name) {
951        return;
952    }
953    if let Some(parent_name) = class_meta.direct_parent_class
954        && let Some(parent_meta) = class_likes.get(&parent_name)
955    {
956        methods.extend(parent_meta.methods.iter().copied());
957        collect_ancestor_methods_inner(parent_meta, class_likes, visited, methods);
958    }
959    for interface_name in &class_meta.direct_parent_interfaces {
960        if let Some(interface_meta) = class_likes.get(interface_name) {
961            methods.extend(interface_meta.methods.iter().copied());
962            collect_ancestor_methods_inner(interface_meta, class_likes, visited, methods);
963        }
964    }
965    for trait_name in &class_meta.used_traits {
966        if let Some(trait_meta) = class_likes.get(trait_name) {
967            methods.extend(trait_meta.methods.iter().copied());
968            collect_ancestor_methods_inner(trait_meta, class_likes, visited, methods);
969        }
970    }
971}
972
973#[cfg(test)]
974mod tests {
975    use std::iter::once;
976
977    use mago_span::Span;
978    use mago_word::WordSet;
979    use mago_word::word;
980
981    use crate::identifier::method::MethodIdentifier;
982    use crate::issue::ScanningIssueKind;
983    use crate::metadata::class_like_constant::ClassLikeConstantMetadata;
984    use crate::metadata::enum_case::EnumCaseMetadata;
985    use crate::metadata::flags::MetadataFlags;
986    use crate::metadata::property::PropertyMetadata;
987    use crate::misc::GenericParent;
988    use crate::misc::VariableIdentifier;
989    use crate::symbol::SymbolKind;
990    use crate::ttype;
991    use crate::ttype::template::GenericTemplate;
992    use crate::ttype::template::variance::Variance;
993    use crate::visibility::Visibility;
994
995    use super::ClassLikeMetadata;
996
997    fn has_code(issues: &[mago_reporting::Issue], kind: ScanningIssueKind) -> bool {
998        let code = kind.to_string();
999        issues.iter().any(|i| i.code.as_deref() == Some(code.as_str()))
1000    }
1001
1002    fn make(name: &str) -> ClassLikeMetadata {
1003        let a = word(name);
1004        ClassLikeMetadata::new(a, a, Span::dummy(0, 10), None, MetadataFlags::empty())
1005    }
1006
1007    #[test]
1008    fn incomplete_hierarchy_dependencies_hide_synthetic_enum_contracts() {
1009        let mut metadata = make("Example");
1010        metadata.kind = SymbolKind::Enum;
1011        metadata.invalid_dependencies.extend([
1012            word("unitenum"),
1013            word("backedenum"),
1014            word("__internal_do_not_use__intbackedenum"),
1015        ]);
1016
1017        assert!(!metadata.has_incomplete_hierarchy());
1018        assert_eq!(metadata.incomplete_hierarchy_dependencies().collect::<Vec<_>>(), []);
1019
1020        let missing = word("vendor\\missing\\contract");
1021        metadata.invalid_dependencies.insert(missing);
1022
1023        assert!(metadata.has_incomplete_hierarchy());
1024        assert_eq!(metadata.incomplete_hierarchy_dependencies().collect::<Vec<_>>(), [missing]);
1025    }
1026
1027    #[test]
1028    fn apply_patch_adds_override_for_inherited_real_method() {
1029        let class_name = word("VendorClass");
1030        let mut vendored = make("VendorClass");
1031        let method_existing = word("existing");
1032        vendored.methods.insert(method_existing);
1033        vendored.declaring_method_ids.insert(method_existing, MethodIdentifier::new(class_name, method_existing));
1034
1035        let mut patch = make("VendorClass");
1036        let method_override = word("inherited_method");
1037        patch.methods.insert(method_override);
1038        patch.declaring_method_ids.insert(method_override, MethodIdentifier::new(class_name, method_override));
1039        patch.appearing_method_ids.insert(method_override, MethodIdentifier::new(class_name, method_override));
1040        patch.inheritable_method_ids.insert(method_override, MethodIdentifier::new(class_name, method_override));
1041
1042        let inherited: WordSet = once(method_override).collect();
1043        vendored.apply_patch(&mut patch, &inherited);
1044        let issues = patch.issues;
1045
1046        // The override is added to the real method set and to all three method-id maps, mirroring
1047        // what the scanner records for a directly-declared method. Seeding declaring/appearing is
1048        // what makes the populator keep this class (not the ancestor) as the declaring site, so the
1049        // refined function-like materialized at `(self, method)` actually wins.
1050        assert!(vendored.methods.contains(&method_override));
1051        assert_eq!(
1052            vendored.declaring_method_ids.get(&method_override),
1053            Some(&MethodIdentifier::new(class_name, method_override)),
1054        );
1055        assert_eq!(
1056            vendored.appearing_method_ids.get(&method_override),
1057            Some(&MethodIdentifier::new(class_name, method_override)),
1058        );
1059        assert!(vendored.inheritable_method_ids.contains_key(&method_override));
1060
1061        // No warning: patch overrides of inherited methods are expected and intentional.
1062        assert!(issues.is_empty());
1063    }
1064
1065    #[test]
1066    fn apply_patch_adds_pseudo_methods() {
1067        let class_name = word("VendorClass");
1068        let mut vendored = make("VendorClass");
1069
1070        let mut patch = make("VendorClass");
1071        let pseudo = word("magicMethod");
1072        patch.pseudo_methods.insert(pseudo);
1073        patch.declaring_method_ids.insert(pseudo, MethodIdentifier::new(class_name, pseudo));
1074        patch.appearing_method_ids.insert(pseudo, MethodIdentifier::new(class_name, pseudo));
1075        patch.inheritable_method_ids.insert(pseudo, MethodIdentifier::new(class_name, pseudo));
1076
1077        vendored.apply_patch(&mut patch, &WordSet::default());
1078        let issues = patch.issues;
1079
1080        // Pseudo-method added to the right sets and ID maps.
1081        assert!(vendored.pseudo_methods.contains(&pseudo));
1082        assert!(vendored.declaring_method_ids.contains_key(&pseudo));
1083        assert!(vendored.appearing_method_ids.contains_key(&pseudo));
1084        assert!(vendored.inheritable_method_ids.contains_key(&pseudo));
1085
1086        // Must not appear as a real method.
1087        assert!(!vendored.methods.contains(&pseudo));
1088
1089        // No issues.
1090        assert!(issues.is_empty());
1091    }
1092
1093    #[test]
1094    fn apply_patch_accepts_new_magic_property() {
1095        let mut vendored = make("VendorClass");
1096
1097        let mut patch = make("VendorClass");
1098        let prop_magic = word("$magic");
1099        patch
1100            .magic_properties
1101            .insert(prop_magic, PropertyMetadata::new(VariableIdentifier(prop_magic), MetadataFlags::PATCH));
1102
1103        vendored.apply_patch(&mut patch, &WordSet::default());
1104        let issues = patch.issues;
1105
1106        assert!(vendored.magic_properties.contains_key(&prop_magic));
1107        assert_eq!(vendored.magic_property_ids.get(&prop_magic), Some(&vendored.name));
1108        assert!(issues.is_empty());
1109    }
1110
1111    #[test]
1112    fn apply_patch_does_not_touch_initialized_or_override_maps() {
1113        let mut vendored = make("VendorClass");
1114        let prop = word("$x");
1115        vendored.properties.insert(prop, PropertyMetadata::new(VariableIdentifier(prop), MetadataFlags::empty()));
1116        vendored.initialized_properties.insert(prop);
1117        vendored.overridden_property_ids.insert(prop, once(word("ParentClass")).collect());
1118
1119        let mut patch = make("VendorClass");
1120        patch.properties.insert(prop, PropertyMetadata::new(VariableIdentifier(prop), MetadataFlags::PATCH));
1121        // patch has no initialized_properties entry and no overridden_property_ids
1122
1123        vendored.apply_patch(&mut patch, &WordSet::default());
1124        let issues = patch.issues;
1125
1126        // initialized_properties must not be cleared by the patch
1127        assert!(vendored.initialized_properties.contains(&prop));
1128        // overridden_property_ids must not be cleared by the patch
1129        assert!(vendored.overridden_property_ids.contains_key(&prop));
1130        assert!(issues.is_empty());
1131    }
1132
1133    #[test]
1134    fn apply_patch_adds_template_types() {
1135        let class_name = word("VendorClass");
1136        let mut vendored = make("VendorClass");
1137
1138        let mut patch = make("VendorClass");
1139        let t = word("T");
1140        patch.template_types.insert(t, GenericTemplate::new(GenericParent::ClassLike(class_name), ttype::get_mixed()));
1141        patch.template_variance.push(Variance::Covariant);
1142        patch.template_readonly.insert(t);
1143
1144        vendored.apply_patch(&mut patch, &WordSet::default());
1145        let issues = patch.issues;
1146
1147        assert!(vendored.template_types.contains_key(&t));
1148        assert_eq!(vendored.template_variance.first().copied(), Some(Variance::Covariant));
1149        assert!(vendored.template_readonly.contains(&t));
1150        assert!(issues.is_empty());
1151    }
1152
1153    #[test]
1154    fn apply_patch_refines_existing_template_and_appends_new() {
1155        let class_name = word("VendorClass");
1156        let mut vendored = make("VendorClass");
1157        let t = word("T");
1158        let u = word("U");
1159
1160        // Original has T (invariant, constraint = mixed) and U (covariant).
1161        vendored
1162            .template_types
1163            .insert(t, GenericTemplate::new(GenericParent::ClassLike(class_name), ttype::get_mixed()));
1164        vendored
1165            .template_types
1166            .insert(u, GenericTemplate::new(GenericParent::ClassLike(class_name), ttype::get_mixed()));
1167        vendored.template_variance = vec![Variance::Invariant, Variance::Covariant];
1168
1169        // Patch refines T (now contravariant) and adds V (invariant).
1170        let mut patch = make("VendorClass");
1171        let v = word("V");
1172        patch.template_types.insert(t, GenericTemplate::new(GenericParent::ClassLike(class_name), ttype::get_int()));
1173        patch.template_types.insert(v, GenericTemplate::new(GenericParent::ClassLike(class_name), ttype::get_string()));
1174        patch.template_variance = vec![Variance::Contravariant, Variance::Invariant];
1175
1176        vendored.apply_patch(&mut patch, &WordSet::default());
1177        let issues = patch.issues;
1178
1179        // T refined, U preserved, V appended → order T=0, U=1, V=2
1180        assert_eq!(vendored.template_types.keys().copied().collect::<Vec<_>>(), [t, u, v]);
1181        assert_eq!(vendored.template_variance, [Variance::Contravariant, Variance::Covariant, Variance::Invariant]);
1182
1183        assert!(issues.is_empty());
1184    }
1185
1186    #[test]
1187    fn apply_patch_preserves_original_only_readonly_template() {
1188        // Original: T is readonly. Patch adds U but does not re-declare T.
1189        // T must remain readonly after the patch — the patch can add readonly
1190        // entries but must not strip existing ones by omitting them.
1191        let class_name = word("VendorClass");
1192        let mut vendored = make("VendorClass");
1193        let t = word("T");
1194        vendored
1195            .template_types
1196            .insert(t, GenericTemplate::new(GenericParent::ClassLike(class_name), ttype::get_mixed()));
1197        vendored.template_variance.push(Variance::Invariant);
1198        vendored.template_readonly.insert(t);
1199
1200        let mut patch = make("VendorClass");
1201        let u = word("U");
1202        patch.template_types.insert(u, GenericTemplate::new(GenericParent::ClassLike(class_name), ttype::get_mixed()));
1203        patch.template_variance.push(Variance::Invariant);
1204
1205        vendored.apply_patch(&mut patch, &WordSet::default());
1206        let issues = patch.issues;
1207
1208        assert!(vendored.template_readonly.contains(&t), "T should remain readonly after patch");
1209        assert!(!vendored.template_readonly.contains(&u), "U was not declared readonly by the patch");
1210        assert!(issues.is_empty());
1211    }
1212
1213    #[test]
1214    fn apply_patch_rejects_kind_mismatch() {
1215        let mut vendored = make("VendorClass");
1216        let mut patch = make("VendorClass");
1217        patch.kind = SymbolKind::Interface;
1218
1219        vendored.apply_patch(&mut patch, &WordSet::default());
1220        let issues = patch.issues;
1221
1222        assert!(has_code(&issues, ScanningIssueKind::PatchKindMismatch));
1223    }
1224
1225    #[test]
1226    fn apply_patch_rejects_trait_use() {
1227        let mut vendored = make("VendorClass");
1228        let mut patch = make("VendorClass");
1229        patch.used_traits.insert(word("SomeTrait"));
1230
1231        vendored.apply_patch(&mut patch, &WordSet::default());
1232        let issues = patch.issues;
1233
1234        assert!(has_code(&issues, ScanningIssueKind::PatchDeclaresTrait));
1235    }
1236
1237    #[test]
1238    fn apply_patch_rejects_hierarchy_mismatch() {
1239        let mut vendored = make("VendorClass");
1240        vendored.direct_parent_class = Some(word("ActualParent"));
1241
1242        let mut patch = make("VendorClass");
1243        patch.direct_parent_class = Some(word("WrongParent"));
1244
1245        vendored.apply_patch(&mut patch, &WordSet::default());
1246        let issues = patch.issues;
1247
1248        assert!(has_code(&issues, ScanningIssueKind::PatchHierarchyMismatch));
1249    }
1250
1251    #[test]
1252    fn apply_patch_rejects_new_method_not_in_ancestors() {
1253        let mut vendored = make("VendorClass");
1254        let mut patch = make("VendorClass");
1255        patch.methods.insert(word("newMethod"));
1256
1257        vendored.apply_patch(&mut patch, &WordSet::default());
1258        let issues = patch.issues;
1259
1260        assert!(has_code(&issues, ScanningIssueKind::PatchIntroducesNewMethod));
1261    }
1262
1263    #[test]
1264    fn apply_patch_rejects_new_property() {
1265        let mut vendored = make("VendorClass");
1266        let mut patch = make("VendorClass");
1267        let prop = word("$newProp");
1268        patch.properties.insert(prop, PropertyMetadata::new(VariableIdentifier(prop), MetadataFlags::PATCH));
1269
1270        vendored.apply_patch(&mut patch, &WordSet::default());
1271        let issues = patch.issues;
1272
1273        assert!(has_code(&issues, ScanningIssueKind::PatchIntroducesNewProperty));
1274    }
1275
1276    #[test]
1277    fn apply_patch_rejects_new_constant() {
1278        let mut vendored = make("VendorClass");
1279        let mut patch = make("VendorClass");
1280        let c = word("NEW_CONST");
1281        patch
1282            .constants
1283            .insert(c, ClassLikeConstantMetadata::new(c, Span::dummy(0, 5), Visibility::Public, MetadataFlags::PATCH));
1284
1285        vendored.apply_patch(&mut patch, &WordSet::default());
1286        let issues = patch.issues;
1287
1288        assert!(has_code(&issues, ScanningIssueKind::PatchIntroducesNewConstant));
1289    }
1290
1291    #[test]
1292    fn apply_patch_rejects_enum_cases() {
1293        let mut vendored = make("VendorClass");
1294        let mut patch = make("VendorClass");
1295        let case = word("CaseA");
1296        patch
1297            .enum_cases
1298            .insert(case, EnumCaseMetadata::new(case, Span::dummy(0, 3), Span::dummy(0, 5), MetadataFlags::PATCH));
1299
1300        vendored.apply_patch(&mut patch, &WordSet::default());
1301        let issues = patch.issues;
1302
1303        assert!(has_code(&issues, ScanningIssueKind::PatchEnumCasesIgnored));
1304    }
1305
1306    #[test]
1307    fn apply_patch_rejects_property_structural_mismatch() {
1308        let mut vendored = make("VendorClass");
1309        let prop = word("$x");
1310        vendored.properties.insert(prop, PropertyMetadata::new(VariableIdentifier(prop), MetadataFlags::empty()));
1311
1312        let mut patch = make("VendorClass");
1313        patch.properties.insert(
1314            prop,
1315            PropertyMetadata::new(VariableIdentifier(prop), MetadataFlags::PATCH | MetadataFlags::STATIC),
1316        );
1317
1318        vendored.apply_patch(&mut patch, &WordSet::default());
1319        let issues = patch.issues;
1320
1321        assert!(has_code(&issues, ScanningIssueKind::PatchPropertyStructuralMismatch));
1322    }
1323
1324    #[test]
1325    fn apply_patch_rejects_constant_structural_mismatch() {
1326        let mut vendored = make("VendorClass");
1327        let c = word("MY_CONST");
1328        vendored.constants.insert(
1329            c,
1330            ClassLikeConstantMetadata::new(c, Span::dummy(0, 5), Visibility::Private, MetadataFlags::empty()),
1331        );
1332
1333        let mut patch = make("VendorClass");
1334        patch
1335            .constants
1336            .insert(c, ClassLikeConstantMetadata::new(c, Span::dummy(0, 5), Visibility::Public, MetadataFlags::PATCH));
1337
1338        vendored.apply_patch(&mut patch, &WordSet::default());
1339        let issues = patch.issues;
1340
1341        assert!(has_code(&issues, ScanningIssueKind::PatchConstantStructuralMismatch));
1342    }
1343}