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