Skip to content

Commit 056e826

Browse files
committed
Skip extends-annotation generation when parent class isn't generic
The extends-emission path in ModelAnnotator::addBehaviorExtends() unconditionally writes a generic extends-annotation pointing at the parent (with the behavior-array and entity-type templates) for every table whose parent has a behavior list or entity to project, regardless of whether the parent class actually declares any template of its own. When the parent isn't generic — e.g. Ratings\Model\Table\RatingsTable from dereuromark/cakephp-ratings — this produces an annotation that PHPStan correctly rejects with the generics.notGeneric error, breaking the consumer's build until the line is removed by hand. Gate the emission on a new parentSupportsGenerics() helper: - Cake\ORM\Table (long-standing TBehaviors template, plus TEntity template from 5.3.4) is treated as generic unconditionally. - Any other parent is reflected and considered generic iff its own docblock declares a template tag of its own — inheriting from a generic ancestor without re-declaring the template doesn't make the subclass generic itself (PHPStan would still report generics.notGeneric on any consumer's extends annotation pointing at the subclass). - A bare extends annotation without generic params is genuinely redundant with PHP's own extends clause, so non-generic parents get no extends-annotation line at all rather than a parameter-less placeholder. The previous "protected-parent" fixture (AbstractTable — not generic) loses its extends-annotation line in the test expectation, and the snapshot annotation count drops accordingly. Two new focused tests assert the two branches: non-generic custom parent emits nothing, and Cake\ORM\Table parent still emits the extends-annotation. Note: orphan extends-annotation lines in already-annotated files are NOT removed automatically — the orphan-pruning pass only touches tag types the current run also generates. Consumers need a one-time manual cleanup, or upstreaming a template tag to their custom base class so the annotator re-emits and replaces the line.
1 parent 98a5b36 commit 056e826

3 files changed

Lines changed: 89 additions & 3 deletions

File tree

src/Annotator/ModelAnnotator.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,13 +593,51 @@ protected function addBehaviorExtends(array $result, array $behaviors, string $p
593593
if (!$parentClass) {
594594
$parentClass = '\\Cake\\ORM\\Table';
595595
}
596+
if (!$this->parentSupportsGenerics($parentClass)) {
597+
// A bare `@extends` adds no information beyond PHP's own `extends` clause,
598+
// and emitting `@extends NonGenericParent<...>` would trigger PHPStan's
599+
// `generics.notGeneric` error on every consumer.
600+
return $result;
601+
}
596602
// Prepend so @extends sits at the top of the class doc-block, matching the
597603
// classOrder defined by php-collective/code-sniffer DocBlockTagOrderSniff.
598604
array_unshift($result, AnnotationFactory::createOrFail(ExtendsAnnotation::TAG, '\\' . $parentClass . '<array{' . $list . '}' . $entityTemplate . '>'));
599605

600606
return $result;
601607
}
602608

609+
/**
610+
* Whether the given parent class declares template parameters and can therefore
611+
* be parameterized via `@extends`.
612+
*
613+
* A class is generic iff its own docblock contains an `@template` tag — inheriting
614+
* from a generic parent without declaring `@template` on the subclass does NOT
615+
* make the subclass generic (PHPStan would report `generics.notGeneric` for any
616+
* `@extends Subclass<...>` annotation in consumers).
617+
*
618+
* @param string $parentClass
619+
* @return bool
620+
*/
621+
protected function parentSupportsGenerics(string $parentClass): bool {
622+
if ($parentClass === '' || ltrim($parentClass, '\\') === Table::class) {
623+
// `\Cake\ORM\Table` declares `@template TBehaviors` (long-standing) and
624+
// `@template TEntity` (from 5.3.4). Either makes the parent generic.
625+
return true;
626+
}
627+
628+
$fqcn = ltrim($parentClass, '\\');
629+
if (!class_exists($fqcn)) {
630+
return false;
631+
}
632+
633+
$doc = (new ReflectionClass($fqcn))->getDocComment();
634+
if ($doc === false) {
635+
return false;
636+
}
637+
638+
return preg_match('/^\s*\*\s*@template\s/m', $doc) === 1;
639+
}
640+
603641
/**
604642
* Whether `\Cake\ORM\Table` declares a second `TEntity` template parameter.
605643
*

tests/TestCase/Annotator/ModelAnnotatorTest.php

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ public function testAnnotateProtectedParent() {
339339
$annotator->annotate($path);
340340

341341
$output = $this->out->output();
342-
$this->assertTextContains(' -> 18 annotations added', $output);
342+
$this->assertTextContains(' -> 17 annotations added', $output);
343343
}
344344

345345
/**
@@ -494,6 +494,56 @@ public function testAnnotateWithEntityTemplateKeepsOverridesForCustomParent() {
494494
$this->assertStringContainsString('patchEntity(', $capture);
495495
}
496496

497+
/**
498+
* Regression: when the parent base table declares no `@template` of its own
499+
* (i.e. is not a generic class), emitting `@extends Parent<...>` would trigger
500+
* PHPStan's `generics.notGeneric` error in every consumer. The annotation must
501+
* be skipped entirely for non-generic parents — the PHP `extends` clause is
502+
* sufficient and a bare `@extends` adds no information.
503+
*
504+
* @return void
505+
*/
506+
public function testAnnotateSkipsExtendsForNonGenericParent() {
507+
$annotator = $this->_getEntityTemplateAnnotatorMock([]);
508+
509+
$capture = '';
510+
$annotator->method('storeFile')
511+
->with($this->anything(), $this->callback(function ($value) use (&$capture) {
512+
$capture = $value;
513+
514+
return true;
515+
}));
516+
517+
$annotator->annotate(APP . 'Model/Table/BarBarsAbstractTable.php');
518+
519+
$this->assertStringNotContainsString('@extends \TestApp\Model\Table\AbstractTable', $capture);
520+
$this->assertStringNotContainsString('@extends', $capture);
521+
}
522+
523+
/**
524+
* Regression: when the parent IS `\Cake\ORM\Table` (the canonical generic parent),
525+
* `@extends Cake\ORM\Table<...>` must still be emitted so consumers benefit from
526+
* the parent's template propagation. Guards against an over-eager skip in
527+
* `parentSupportsGenerics()`.
528+
*
529+
* @return void
530+
*/
531+
public function testAnnotateEmitsExtendsForGenericCakeTableParent() {
532+
$annotator = $this->_getEntityTemplateAnnotatorMock([]);
533+
534+
$capture = '';
535+
$annotator->method('storeFile')
536+
->with($this->anything(), $this->callback(function ($value) use (&$capture) {
537+
$capture = $value;
538+
539+
return true;
540+
}));
541+
542+
$annotator->annotate(APP . 'Model/Table/BarBarsTable.php');
543+
544+
$this->assertStringContainsString('@extends \Cake\ORM\Table<', $capture);
545+
}
546+
497547
/**
498548
* Regression: in `concreteEntitiesInParam` mode the `save()` and `saveOrFail()`
499549
* overrides narrow `$entity` to the concrete entity class, which the parent's

tests/test_files/Model/Table/BarBarsAbstractTable.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
namespace TestApp\Model\Table;
33

44
/**
5-
* @extends \TestApp\Model\Table\AbstractTable<array{MyMy: \MyNamespace\MyPlugin\Model\Behavior\MyBehavior, Timestamp: \Cake\ORM\Behavior\TimestampBehavior}>
6-
*
75
* @property \Cake\ORM\Association\BelongsTo<\TestApp\Model\Table\FoosTable> $Foos
86
* @property \Cake\ORM\Association\BelongsToMany<\Awesome\Model\Table\HousesTable> $Houses
97
*

0 commit comments

Comments
 (0)