PHPStan level 9 na velké starší kódové základně

Jak jsme CMS s více než šestnácti tisíci chyb PHPStan v baseline začali postupně odstraňovat, aniž bychom zastavili vývoj: postup s baseline, jejíž počet nesmí růst, a pro každou opakující se třídu chyb oprava, kterou dnes používáme reflexivně.

Lukáš Oreška 19 min čtení

Výchozí stav

Aplikace je modulární CMS s front endem, administrací a interní vrstvou API, psané mnoho let. PHPStan běžel na level 9, ale většina chyb ležela v baseline souborech po jednotlivých etapách: na začátku práce to bylo 16 611 ignorovaných chyb. Baseline je užitečné příměří, ne oprava: každá ignorovaná chyba je místo, kde nás analyzátor přestal chránit.

Cíl

Nulová baseline dosažená postupně, zatímco se na stejné větvi normálně vyvíjí.

Omezení

Žádné změny chování skryté v práci s typy. Každá oprava je buď neutrální, nebo záměrná oprava chyby pojmenovaná v commitu.

V době psaní je jich 780 a práce po cestě odhalila víc než tucet chyb viditelných uživatelům.

1. Pravidla hry

Doporučení samotného PHPStan je stručné a převzali jsme je jako závazná pravidla. Každé existuje proto, že zkratka skrývá vadu, na kterou chyba ukazuje.

  • Žádné komentáře @phpstan-ignore a žádné nové záznamy v baseline pro nový kód.
  • Žádná přetypování přidaná jen kvůli umlčení chyby. (int) $row['id'] bez protestu změní pole na 1.
  • Žádná přepsání přes inline @var a žádné assert() pro vynucení typu.
  • Žádné rozšiřování typů parametrů nebo návratových hodnot jen proto, aby chyba zmizela.
  • Opravte příčinu: chybějící rozhraní, nullovatelnou hodnotu, kterou nikdo neošetřil, větev, která se nikdy nespustí.

2. Postup s baseline: západka (ratchet), pročištění a soubor pro plný průchod

Tři malé nástroje dělají postupný pokrok bezpečným.

Západka (ratchet)

Pre-commit hook spočítá každý záznam v baseline vážený jeho count, porovná součet s HEAD a commit odmítne, pokud vzrostl. Pokrok může jít jen jedním směrem.

php tests/Manual/System/phpstan_baseline_ratchet.php
# PHPStan baseline ratchet: HEAD=12449  now=11989  -460

Pročištění

Po opravě zastarají odpovídající záznamy v baseline. Pročištění přečte výstup PHPStan v JSONu a ve všech souborech baseline odstraní nebo sníží záznamy, které už neodpovídají.

vendor/bin/phpstan analyse --error-format=json > result.json
php tests/Manual/System/phpstan_baseline_prune.php --write --json=result.json

Soubor pro plný průchod

Starší baseline po etapách zůstávají, jak jsou. Všechno ostatní se sbírá v jednom znovu generovaném souboru. Na pořadí generování záleží: dočasně odeberte include souboru pro plný průchod, pročistěte ostatní soubory, soubor pro plný průchod vygenerujte znovu, vraťte include a pročistěte ještě jednou.

3. Hodnoty mixed z databáze a požadavků

Příznak. Cannot cast mixed to int, Parameter #1 expects int, mixed given u každého fetchAssociative(), fetchOne() i parametru požadavku.

Oprava. Jeden drobný pomocník, který se u skalárů a null chová přesně jako přetypování, a pro cokoli, co tam patřit nemůže, vyhodí výjimku:

final class DbValue
{
    public static function int(mixed $value): int
    {
        if (\is_scalar($value) || $value === null) {
            return (int) $value;
        }
        throw new \UnexpectedValueException('Expected a number, got ' . \get_debug_type($value) . '.');
    }

    public static function nullableInt(mixed $value): ?int { return $value === null ? null : self::int($value); }
    public static function string(mixed $value): string { /* same idea, also accepts Stringable */ }
}

Stávající chování zůstává u platných dat beze změny, takže se to dá aplikovat mechanicky. Rozdíl je v tom, že vnořené pole se už potichu nezmění na 1.

4. getId() na objektech, které PHPStan nevidí

Příznak. Cannot call method getId() on object nebo na mixed. Byl to největší shluk. Kolekce, generické manažery a proxy vracejí obyčejný object a kód volá getId() na čemkoli, co přijde.

Oprava. Jedno rozhraní, které implementuje každá entita s identifikátorem, a pomocník pro místa volání, kde je statický typ pořád volný:

interface IdentifiableInterface
{
    public function getId(): ?int;
}

final class EntityId
{
    public static function of(mixed $entity): ?int
    {
        return $entity instanceof IdentifiableInterface ? $entity->getId() : null;
    }
}

Důležité je, co následuje. Návratový typ je teď poctivě ?int a PHPStan sleduje to null na každé místo, které předpokládalo celé číslo. Každé z nich vyžaduje rozhodnutí:

Situace Rozhodnutí
Sestavení seznamu nebo voleb výběru Přeskočit položky bez id (continue).
Mazání podle id Vrátit neúspěšný výsledek: neuloženou entitu nelze smazat.
Autentizace Vyhodit výjimku: uživatel bez id se nikdy nesmí přihlásit.
Logování Vypsat jako prázdné. Nikdy nevkládejte statické volání do řetězce ve dvojitých uvozovkách (viz oddíl 12).

5. Netypovaná pole odpovědí

Příznak. Cannot access offset 'message' on mixed, opakovaně v každém repozitáři, který čte obálku odpovědi interního API.

Oprava. Malá třída pro čtení odpovědi s jednou metodou pro každé pole, každá s bezpečnou výchozí hodnotou:

ApiEnvelope::isSuccess($response);            // true only for success === true
ApiEnvelope::message($response, 'Save failed.');
ApiEnvelope::code($response);                 // always a ResultCode
ApiEnvelope::arrayData($response);            // array or null

6. Nullovatelné kontextové objekty

Příznak. Kontext vázaný na požadavek, u nás aktuální jazyk, ukládal hodnotu jako object. Každý spotřebitel volal getLanguage()->getId() a výsledek předával metodám, které čekaly int.

Oprava. Uloženou hodnotu otypujte skutečnou třídou entity a spotřebitelům dejte dva výslovné přístupové body. Každý volající pak uvede svůj záměr:

public function getLanguageId(): ?int
{
    return $this->language?->getId();
}

public function requireLanguageId(): int
{
    return $this->getLanguageId() ?? throw new \LogicException('Language is not set in the context.');
}

Presentery front endu, které vždy běží po vyřešení jazyka, používají requireLanguageId(). Služby na pozadí, například odesílání e-mailů, přijímají null a výslovně se vrátí k výchozímu jazyku.

7. Duck typing a mrtvé větve, které skrývá

Příznak. Call to an undefined method na generickém manažeru nebo mapperu, obvykle hlídané pomocí method_exists().

Oprava. Zaveďte rozhraní, které kód už předpokládal (u nás rozhraní mapperu, rozhraní stromového mapperu a rozhraní stromového manažeru), a deklarujte ho na třídách. Hlídání pomocí method_exists() zmizí.

Jakmile jsou typy poctivé, PHPStan začne hlásit instanceof ... will always evaluate to true a Result of && is always false. Berte to jako nálezy, ne šum: označují kód, který se nikdy nespustí. Typický případ je rozbalení líné proxy pro hodnotu, o které je už zaručeno, že je skutečná entita. Takové větve smažte místo toho, abyste je dávali do baseline.

Výsledky nejsou přepravka na typovaná data

Pomocník, který vracel tabulku, sloupce a rozsah uvnitř Result::success(data: [...]), ztratil cestou všechny typy, protože getData() je mixed. Sedm operací nad stromem pak předávalo mixed do typovaných metod repozitáře. Malý readonly hodnotový objekt vracený jako TreeStructure|Result (kde Result znamená selhání) odstranil přetypování i chyby najednou.

Traity, které skončily ve špatné třídě

Chyby jako „call to an undefined method CategoryPresenter::initUser()“ často znamenají, že trait byl přidán třídě, která ho nikdy nepotřebovala. U nás presenter přehledu kategorií používal trait pro detail inzerátu a spolu s ním zpřístupnil potvrzovací signál a dvě komponenty formuláře na stránce, která žádný inzerát nemá. Šablona nic z toho nepoužívala, takže opravou bylo trait odstranit.

8. Mapování asociací v Doctrine ORM 3

Příznak. Přístup přes offset k mapování asociací, například $mapping['joinColumns'][0]['name'], neprojde analýzou, protože ORM 3 vrací objekty mapování, ne pole.

Oprava. Jeden pomocník, který zúží na správnou třídu mapování a jinak hlasitě selže:

public static function joinColumnName(AssociationMapping $mapping): string
{
    if ($mapping instanceof ToOneOwningSideMapping) {
        return $mapping->joinColumns[0]->name;
    }
    throw new \LogicException('Association ' . $mapping->fieldName . ' has no join column.');
}

public static function mappedBy(AssociationMapping $mapping): ?string
{
    return $mapping instanceof InverseSideMapping ? $mapping->mappedBy : null;
}

8b. Názvy tříd: class-string, nebo kontrola

Příznak. expects class-string<object>, string given u getClassMetadata(), getRepository() a getByType() v DI kontejneru.

Oprava. Závisí na tom, odkud se název bere:

  • Předaný volajícím: anotujte parametr @param class-string $entityClass a nechte PHPStan procházet řetěz směrem nahoru. Malý skript krok opakoval, dokud se neobjevila žádná nová místa.
  • Sestavený za běhu (názvy modulů a komponent, třída továrny uložená v databázi, platební callback): zkontrolujte ho pomocí class_exists(). Pomocník jako hasServiceByType() může nést @phpstan-assert-if-true class-string $className, takže kontrola zároveň zúží typ.
  • Ne vždy třída: „typ vlastnosti“ hydrátoru může být také int. Tady je správnou opravou kontrola isEntity() s tvrzením, ne anotace class-string.

Související past: array_map('intval', $ids) na poli typu mixed potichu změní prvek pole na 1. Pomocník DbValue::ints() přetypovává skaláry přesně jako (int), zahazuje prvky, které nemohou být čísla, a seznam nechává seznamem.

9. Generika a hranice šablon

Základní manažery, služby a mappery sdílejí desítky modulů. Zgenerizovat je jedním krokem by se dotklo všech modulů najednou. Tomu se vyhnou dvě funkce PHPStan.

Výchozí hodnoty šablon nechají nepřevedené moduly fungovat jako dřív. Když má každá šablona výchozí hodnotu rovnou své hranici, neparametrizovaný Advert v signatuře prostě znamená „hranici“ a missingType.generics se nehlásí. Přidání = bound k 539 šablonám entit a do vrstev služeb a manažerů odstranilo asi 2 400 záznamů z baseline, aniž by se sáhlo na kód za běhu:

/**
 * @template TEntity of BaseEntity = BaseEntity
 */
abstract class Manager { /* ... */ }

Variance v místě volání nechá sdílený kód přijmout jakoukoli specializaci bez chyb invariance:

/** @param Manager<covariant ServiceInterface<covariant BaseEntity>, covariant BaseEntity> $manager */

Moduly se pak jeden po druhém převádějí přidáním @extends Manager<Product> a každý krok odstraní kus baseline.

Omezte šablony na to, co kód skutečně volá

Entity s překlady měly @template TTranslation of object, takže každé $translation->getLanguageId() ve sdíleném traitu bylo chybou „undefined method“, opakovanou u každé z 54 entit. Oprava je rozhraní pro to, co kód volá, a hranice jako průnik, protože nativní typy traitu pořád říkají BaseEntity:

interface TranslationInterface
{
    public function getLanguageId(): ?int;
}

/** @template TTranslation of BaseEntity&TranslationInterface */
trait TranslationCollectionTrait { /* ... */ }

Globální alias typu udržuje docblocky čitelné a v typech parametrů by měl být covariant, aby se přijalo Status<StatusText>:

# phpstan.neon
parameters:
    typeAliases:
        TranslationEntity: 'App\Core\Base\BaseEntity&App\...\TranslationInterface'

/** @param OrderStatus<covariant TranslationEntity> $status */

Zpřísnění hranice promění každé X<object> v docblocku v chybu, což je užitečné. Těch 131 míst se přepsalo skriptem, který čte pořadí @template v každé třídě a nahrazuje jen argument na pozici překladu. Stejné zpřísnění odhalilo entitu deklarovanou jako @extends BasePage<Route, PageText> s vyměněnými argumenty. S hranicemi object to potichu procházelo a jeden volající nesl inline @var, aby to obešel. Tento jediný krok odstranil 528 záznamů z baseline.

10. Pole: typy hodnot a tvary

Příznak. should return array<int, string> but returns array<int|string, string>. Generický pomocník pro seznamy nemůže vědět, že každý klíč je celé číslo.

Typy hodnot pro parametry s obyčejným array

Parametr typovaný jen jako array vyvolá missingType.iterableValue. Asi tisíc jsme jich doplnili podle názvu parametru, podle konvence, kterou už používalo rozhraní mapperu: *Ids se stane array<int>, řádky, parametry a hodnoty array<string, mixed> a cokoli jiného array<array-key, mixed>.

Nechte PHPStan říct návratový typ

U metody, která vrací array bez typu hodnot nebo nemá návratový typ vůbec, nemusíte hádat. Dočasně přidejte záměrně špatnou značku: @return array<never, never> pro pole, @return void pro netypované metody. PHPStan pak u každého return ohlásí „should return … but returns X“ a X je typ, který odvodil. Hodnoty X sloučte po metodách a zapište zpět. Ponechte jen konkrétní výsledky: přeskočte cokoli s literálními hodnotami, cokoli velmi dlouhého a výrazy šablon jako T of object = object. Názvům tříd přidejte předponu \, protože PHPStan je vypisuje bez ní. Nezapisujte vágní výsledky jako array<array-key, mixed>: odhalují navazující chyby mixed, aniž by přidaly přesnost.

Tvary polí z generických pomocníků. Pokud smyčka už existuje, sestavte mapu přímo v ní s klíči, které jste už zkontrolovali. Je to kratší než volání pomocníka a plně typované:

$options = [];
foreach ($items as $item) {
    $id = EntityId::of($item);
    if ($id === null) {
        continue;
    }
    $options[$id] = $item->getTranslation($langId)?->getName() ?? '';
}

Stejný vzor opraví array_map(fn ($x): int => EntityId::of($x), ...): mapujte na ?int a pak použijte array_filter a array_values.

11. Testy, které se zpřísňují s vámi

Unit testy často nahrazují entity anonymními třídami, které mají jen metodu getId(). Když se parametr stane ?Language nebo EntityId::of() kontroluje rozhraní, tyto náhražky buď vyvolají TypeError, nebo se potichu změní na null, což je horší.

  • Dávejte přednost skutečné instanci entity: (new Language())->setId(1).
  • Pokud se anonymní třídě nelze vyhnout, nechte ji implementovat rozhraní.
  • Z nahrazeného presenteru vracejte skutečný kontextový objekt místo vnořené anonymní třídy.

12. Hromadné úpravy, které kousnou

Většina těchto oprav se aplikovala skripty napříč stovkami souborů. Tyto pasti se objevily víckrát:

Past Výsledek Pojistka
Regulární výraz se zastaví u ( nebo ?-> DbValue::int($conn->executeQuery) bez argumentů Po každé dávce spusťte PHPStan. Zachytil oba případy.
Volání pomocníka uvnitř řetězce ve dvojitých uvozovkách "user={EntityId::of($u)}" za běhu selže, protože PHP statická volání neinterpoluje Prohledání tokenizerem na názvy pomocníků uvnitř tokenů řetězců
Přenesená úvodní zpětná lomítka \ApiEnvelope:: se přeložilo do globálního jmenného prostoru Lint a PHPStan
Smíšené odsazení Vzor napsaný pro tabulátory neodpovídá souboru odsazenému mezerami Ověřte počet shod a skript zastavte
Vkládání return; jako pojistky Fatální chyby v nevoid metodách php -l na každém změněném souboru

Každý skript ověřuje, že každý vzor odpovídal přesně tolikrát, kolikrát se čekalo. Skript, který hlasitě selže v půlce, je mnohem lepší než ten, který tiše přeskočí soubor.

13. Kde se typy ztratily

V polovině práce už většina zbývajících chyb neukazovala na špatný kód. Ukazovala na hodnotu, jejíž skutečný typ vývojář znal, ale cestou k PHPStan se ztratil. Každé místo níže změnilo konkrétní entitu zpět na BaseEntity nebo object. Oprava zdroje odstranila najednou desítky chyb v navazujícím kódu.

Kde Příznak Oprava
Koncová entita bez @extends Base<…> Každá šablona sdílené báze spadne na výchozí hodnotu Odvoďte argumenty: pro každou šablonu najděte vlastnost báze typovanou jí a přečtěte targetEntity z mapování Doctrine koncové třídy
Sdílená třída používající trait kolekce bez @use Trait<T> Metody traitu vracejí Collection<int, BaseEntity> Přidejte @use těsně nad řádek use
Fluentní settery deklarované jako : self (new Message())->setA()->setB() se změní na sdílenou základní třídu @return $this u každého setteru, jehož tělo jen vrací $this
Fluentní objekt dotazu, jehož include() vrací obyčejné self findAll()->include([…])->execute() vrací BaseEntity[] @return self<T> u každé fluent metody
getEntityClass(): class-string Generický hydrátor nemůže vrátit entitu manažeru class-string<TEntity> podél celého řetězce služba → manažer
Komentář /** @var … */ mezi dvěma atributy Vlastnost si nechává holý nativní typ (Collection bez typů prvků); PHP komentář čte, PHPStan ne Přesuňte komentář nad první atribut. Parser komentář připojí k prvnímu tokenu deklarace, což je atribut. Našli jsme jich 43 prohledáním tokenizerem
Pomocník odpovědi deklarovaný jako : mixed Každý volající ho „pro jistotu“ obalí is_array() Deklarujte skutečný tvar pole; pojistky se změní na „always true“ a zmizí

S tím přišly dvě provozní lekce. Za prvé: po změně generik u základní třídy může cache výsledků PHPStan dál hlásit starý stav; než nečekanému výsledku uvěříte, vyčistěte ji. Za druhé: PHPStan 2.2 vypisuje vlastní radu na stderr: nepřepisujte odvozený typ inline @var ani assert(). U mixed z Doctrine (části dotazů, skalární výsledky) ho zužte za běhu pomocí is_array() nebo instanceof.

Ještě jedna past hromadných úprav: skript, který přidá /** @return $this */ nad každý setter, najde i settery uvnitř zakomentovaného bloku /* … */. Vložené */ komentář předčasně uzavře a rozbije soubor. Přeskakujte řádky, které tokenizer hlásí jako komentáře.

Dvě pasti frameworku nás stály po jedné regresi. V Nette čte typ parametru action*() také generátor odkazů: netypovaný nebo nullovatelný parametr je volitelný, kdežto int $order bez výchozí hodnoty způsobí, že každý odkaz na tu akci selže s „Missing parameter“. Parametry akcí typujte jako nullovatelné. A $form['x'] vrací obecné rozhraní komponenty, takže metody jako setRequired() nebo addConditionOn() jsou „undefined“. Inline @var nad tím nic nezúží; pomůže malý typovaný přístupový bod na vaší třídě formuláře ($form->typedComponent('x', BaseControl::class), který při neshodě vyhodí výjimku).

Ještě jedna disciplína: po hromadném odstranění „nepoužívaných“ vlastností znovu spusťte analýzu bez baseline a hledejte property.notFound. Náš vlastní úklid odstranil závislosti konstruktoru, které továrna pořád četla, a crawler si toho nevšiml, protože dotčená stránka byla za formulářem s heslem.

Ne každá chyba ukazuje na kód. Nette volá obsluhu formuláře s hodnotami v typu, který obsluha deklaruje: array, ArrayHash nebo třída DTO. Stub phpstan-nette pořád říká callable(static, mixed), takže se odmítla každá správně otypovaná obsluha. Druhý stub pro stejnou třídu není povolen, protože validace stubů ohlásí, že je deklarován dvakrát. Vlastnost jsme znovu deklarovali na vlastní podtřídě Form aplikace jako callable(static, never). Zbyl tak jeden poctivý nesoulad s vendorským stubem místo asi padesáti falešných chyb.

Pozdní lekce z posledního tisíce

  • Některé typy je lepší nechat implicitní, dokud neexistuje přístupový bod. Zápis @return array<int, array<string, mixed>> u getterů nastavení a konfigurace změnil asi 140 nekontrolovaných použití jako all()[0]['uploadDir']['abs'] na explicitní chyby mixed. Poctivá oprava je typovaný přístupový bod, ne docblock: Config::path(1, 'uploadDir', 'abs') vrací řetězec (prázdný, když chybí) a Config::value(1, 'mail') vrací surovou hodnotu ke kontrole na straně volajícího. Po přepsání jedenácti řetězců offsetů šlo gettery otypovat bez jediné nové chyby; hodnoty čtené přes getter nastavení procházejí DbValue::int() místo (int).
  • Parametry předávané odkazem přepisují typ volajícího. Pomocník prepareCategoryFilter(array &$filters) dokumentovaný jako array<array-key, mixed> po volání změnil pečlivě otypované array<int, Filter> volajícího na array<mixed>. Parametr předávaný odkazem otypujte stejně přesně jako volající, nebo použijte @param-out.
  • Hranice šablony nemůže ukazovat na šablonu deklarovanou až za ní. @template TService of ServiceInterface<TEntity> nad @template TEntity vyrobilo 309 chyb „not subtype“, protože hranice pozdější šablonu nevyhodnotí. Skutečná oprava znamená přeřazení šablon a každého @extends; nechali jsme ji na samostatný krok.
  • Stuby potřebují rodiče, které jdou autoloadem. Stub, který opravoval jeden chybný @return object v platebním SDK, selhal při validaci stubů („extends unknown class“), protože SDK se načítá jen přes classmap. Jednodušší opravou byla kontrola instanceof v našem místě volání.
  • Typovaný přístupový bod funguje i pro komponenty. Stejné typedComponent() použité pro prvky formuláře, přidané do sdíleného traitu komponent, nahradilo každé inline @var nad $this['x'] v presenterech a komponentách. Jeden z těch komentářů uváděl třídu, která neexistovala.
  • Testy nad zdrojovým textem odolávají „neškodným“ úpravám. Test ověřoval přesný text volání, takže přidání ?? '' v místě volání ho rozbilo. Stejnou chybu opravilo přijetí ?string ve volaném pomocníkovi, aniž se sáhlo na volání.

Skutečné chyby nalezené cestou

Práce s typy není kosmetická. Mezi vadami opravenými při odstraňování víc než 10 000 záznamů z baseline:

  • Výběr sazby DPH, který se vždy vykreslil prázdný.
  • Chybějící šablona, která způsobila HTTP 503 místo náhradního řešení.
  • Obálka findAllBy(), která vracela nanejvýš jednu entitu.
  • Aritmetika stránkování rozbitá prioritou operátorů.
  • Mazání stromu, které mohlo smazat uzly dřív, než ověřilo data podstromu.
  • Číselné URL kategorií vracející HTTP 500 místo vyřešení nebo odpovědi 404.
  • Stránky soft 404 odpovídající stavem 200.
  • Aktivační e-mail, který se mohl odeslat ve špatném jazyce.
  • Chybová zpráva při přihlášení sestavená z kódu výsledku null.
  • Stránka detailu inzerátu, která volala metodu, jež na jejím objektu metadat neexistovala. Nikdy nespadla jen proto, že obrázek, na kterém závisela, byl vždy prázdný, takže se obrázky Open Graph tiše nikdy negenerovaly. Přepnutí nullovatelné vlastnosti na nenullovatelnou odhalilo oba problémy.
  • Stránky detailu článku, které nikdy nenačetly hlavní obrázek, takže neměly ani obrázek Open Graph. Druhá entita měla vyměněné generické argumenty (uživatel a obrázek), stejně jako entita stránky výše.
  • Přehled kategorií, který zdědil trait pro detail inzerátu, a s ním signál a dva formuláře, které nikdy nepotřeboval.
  • Editační formulář inzerátu, který vždy načítal výchozí sadu parametrů. Kód procházel relace kategorií inzerátu a ponechával položky, které byly instanceof Category, což relace nikdy není, takže seznam kategorií byl vždy prázdný.
  • Stránka detailu firmy, která deduplikovala objekty kategorií pomocí array_unique(), jež převádí hodnoty na řetězce a u objektů vyhodí chybu. Přežívala jen proto, že kategorie ještě nebyly načtené.
  • Počítadlo příspěvků v diskuzi, které volalo metodu na vláknu komentářů, které může být null.
  • Služba, která hlásila špatnou třídu entity (kopie ze sourozenecké služby), takže zneplatňování cache a kontroly oprávnění mířily na jinou tabulku.
  • Dvě metody kolekce clear…(), které volaly clear() na neexistující vlastnosti.
  • Kontrola v query builderu, která porovnávala řetězec aliasu s objekty Select, nikdy nesouhlasila a přidala kořenový alias do SELECT dvakrát.
  • Cesta sestavená jako $root . $row['path'] ?? '': spojení řetězců váže jako první, takže chybějící klíč vyvolal varování místo náhradní hodnoty.
  • Pole z query stringu (?curr[]=x, ?date[]=x) se dostalo do vlastnosti typu řetězec nebo do new DateTime() a každou stránku proměnilo v HTTP 500. Hodnoty čtené z URL jsou v PHPStan mixed z dobrého důvodu.
  • Result::fromArray([]) pro odpověď API, která není JSON, spadlo na ResultCode::fromValue(null).
  • Celý mechanismus dekorátorů za běhu, který při každém požadavku procházel vlastnosti presenteru a nic neměnil: každý setter, který hledal, byl dávno zakomentovaný. Jeden dekorátor dokonce kontroloval setCurrency a pak volal setCurr.
  • Požadavek na API s neznámým jazykem spadl na TypeError: načítač byl deklarován, že vrací lokalizaci, zatímco vyhledávání mohlo vrátit null, což volající už ošetřoval.
  • Komponenta seznamu komentářů deklarovala návratový typ s jmenným prostorem, který neexistoval, takže vytvoření jejího formuláře mohlo skončit jen TypeError.
  • Dvě entity používaly trait „identifiable“, aniž by měly vlastnost $id; jakékoli volání getId() by četlo nedefinovanou vlastnost.
  • Starší serializovaný formát variant obrázků obcházel normalizér, kterým procházel formát JSON, takže staré varianty vracely netypované hodnoty.
  • Aktivační e-mail newsletteru bez adresy a oznámení o čekající publikaci pro komponentu bez překladové domény skončily oba na TypeError místo ošetřeného selhání.

Kontrolní seznam

  • Západka (ratchet) v pre-commit hooku: počet v baseline nesmí růst.
  • Po každém pročištění nebo novém generování plný běh PHPStan vypíše [OK] No errors.
  • mixed z úložiště prochází přes DbValue, ne přes přetypování.
  • Identifikátory pocházejí z rozhraní; ?int se ošetřuje tam, kde se objeví, s výslovně uvedeným rozhodnutím.
  • Kontextové objekty ukládají skutečné typy a nabízejí výslovnou dvojici get…Id() a require…Id().
  • Hlášení „always true“ a „always false“ se berou jako mrtvý kód ke smazání.
  • Sdílené základní třídy se stávají generickými s výchozími hodnotami šablon a variancí v místě volání, jeden modul po druhém; hranice šablon pojmenovávají rozhraní, které kód skutečně volá.
  • Skripty pro hromadné úpravy ověřují počty shod; každá dávka končí php -l, PHPStan a unit testy.
  • Testovací stuby používají skutečné entity nebo implementují rozhraní.
  • Každá koncová entita má @extends, každá sdílená třída má @use pro své generické traity a fluent metody vracejí $this nebo self<T>.
  • Po změně generik u základní třídy vyčistěte cache výsledků PHPStan, než začnete věřit číslům.

PHPStan level 9 na velké starší kódové základně

Jak jsme CMS s více než šestnácti tisíci chyb PHPStan v baseline začali postupně odstraňovat, aniž bychom zastavili vývoj: postup s baseline, jejíž počet nesmí růst, a pro každou opakující se třídu chyb oprava, kterou dnes používáme reflexivně.
19 min čtení

Publikace Android aplikace v Google Play: od Play Console po interní testování

Všechno, na co se Play Console ptá, než aplikace dorazí k prvním testerům: prohlášení o obsahu aplikace, Data safety, stránka v obchodě, nové prohlášení o médiích vytvořených AI, veřejně dostupné zásady ochrany soukromí, interní testování, podepisování aplikace a co čekat u ceny a daní.
9 min čtení

Vydání Expo aplikace bez EAS: lokální podepsané buildy pro Android

Jak na vlastním Macu vyrobit z projektu Expo SDK 57 správně podepsaný Android App Bundle připravený pro Play: keystore pro nahrávání (upload keystore) mimo repozitář, config plugin, který přežije prebuild, jedna oprava paměti Gradle a skript, který odmítne skončit, pokud je podpis špatný.
9 min čtení

Řešíte něco podobného na svém webu?

Napište mi, odpovím do druhého dne.

Napsat zprávu