@@ -129,6 +129,28 @@ def desc(self):
129129 return f"line { self .lineno } : redundant eclass inherit { self .line !r} , provided by { self .provider !r} "
130130
131131
132+ class ShadowedEclassPhase (results .VersionResult , results .Style ):
133+ """Ebuild does not define a phase when inheriting multiple eclasses
134+ exporting that phase.
135+
136+ When inheriting multiple eclasses exporting the same phase, a custom
137+ phase must usually be defined in the ebuild to call the phase exported
138+ from each eclass.
139+
140+ Exceptions exist where the functionality isn't needed, but it should
141+ be a deliberate choice, not an accidental omission.
142+ """
143+
144+ def __init__ (self , phase , providers , ** kwargs ):
145+ super ().__init__ (** kwargs )
146+ self .phase = phase
147+ self .providers = tuple (providers )
148+
149+ @property
150+ def desc (self ):
151+ return f"missing custom phase for { self .phase !r} , provided by eclasses: { ', ' .join (self .providers )} "
152+
153+
132154class EclassUsageCheck (Check ):
133155 """Scan packages for various eclass-related issues."""
134156
@@ -142,6 +164,7 @@ class EclassUsageCheck(Check):
142164 EclassUserVariableUsage ,
143165 MisplacedEclassVar ,
144166 ProvidedEclassInherit ,
167+ ShadowedEclassPhase ,
145168 }
146169 )
147170 required_addons = (addons .eclass .EclassAddon ,)
@@ -254,6 +277,59 @@ def check_provided_eclasses(self, pkg, inherits: list[tuple[list[str], int]]):
254277 for provided , (eclass , lineno ) in provided_eclasses .items ():
255278 yield ProvidedEclassInherit (eclass , pkg = pkg , line = provided , lineno = lineno )
256279
280+ def check_exported_eclass_phase (
281+ self , pkg : bash .ParseTree , inherits : list [tuple [list [str ], int ]]
282+ ):
283+ """Check for eclasses exporting the same phase where the ebuild does not
284+ call such phases manually."""
285+ latest_eapi = EAPI .known_eapis [sorted (EAPI .known_eapis )[- 1 ]]
286+ # all known build phases, e.g. src_configure
287+ known_phases = list (latest_eapi .phases_rev )
288+ exported_phases = {phase : [] for phase in known_phases }
289+
290+ # Create a dict of known phases => eclasses exporting them:
291+ # we're interested in the cases where the RHS list has > 1 element.
292+ for eclasses , _ in inherits :
293+ for eclass in eclasses :
294+ for func in self .eclass_cache [eclass ].functions :
295+ phase = func .name .removeprefix (f"{ eclass } _" )
296+ if phase in known_phases :
297+ exported_phases [phase ].append (eclass )
298+
299+ if not exported_phases .keys ():
300+ return
301+
302+ defined_phases = []
303+ for node in bash .func_query .captures (pkg .tree .root_node ).get ("func" , ()):
304+ func_name = pkg .node_str (node .child_by_field_name ("name" ))
305+ if func_name in known_phases :
306+ defined_phases .append (func_name )
307+
308+ # XXX: Some eclasses vary their API based on the EAPI, usually to
309+ # 'unexport' a phase. self.eclass_cache is generated once per eclass,
310+ # not (eclass, EAPI), so it can't handle this. Ditto phases which are only
311+ # exported if a variable is set. Blacklist such eclasses here as it
312+ # doesn't happen often.
313+ #
314+ # We could maybe make this more finely-grained for phases we know
315+ # are conditionally exported if this list is impacting coverage
316+ # severely.
317+ blacklisted_eclasses = ["pypi" , "vala" , "xdg" ]
318+ exported_phases = {
319+ phase : set (eclass ) - set (blacklisted_eclasses )
320+ for phase , eclass in exported_phases .items ()
321+ }
322+
323+ # Strip out phases we already define (even if inside of those, we don't
324+ # actually call exported phases from all eclasses inherited). Assume that
325+ # a custom phase in the ebuild is intentionally omitting them.
326+ missing_custom_phases = set (
327+ phase for phase , eclass in exported_phases .items () if len (eclass ) > 1
328+ ) - set (defined_phases )
329+
330+ for missing in missing_custom_phases :
331+ yield ShadowedEclassPhase (missing , sorted (exported_phases [missing ]), pkg = pkg )
332+
257333 def feed (self , pkg ):
258334 if pkg .inherit :
259335 inherited : set [str ] = set ()
@@ -283,6 +359,7 @@ def feed(self, pkg):
283359 # verify @DEPRECATED variables or functions
284360 yield from self .check_deprecated_variables (pkg , inherits )
285361 yield from self .check_deprecated_functions (pkg , inherits )
362+ yield from self .check_exported_eclass_phase (pkg , inherits )
286363
287364 for eclass in pkg .inherit .intersection (self .deprecated_eclasses ):
288365 replacement = self .deprecated_eclasses [eclass ]
0 commit comments