Skip to content

2.3.0

Latest

Choose a tag to compare

@Chemaclass Chemaclass released this 15 Aug 09:25
· 35 commits to main since this release
2.3.0
1f1e5fa

Added

Doctor checks

  • plugin stacks and handler registries report a registration that cannot be satisfied: a missing class, or one that does not implement its contract. Both resolve on first access, so nothing else checks them; registry findings name the key
  • unresolved pillar files and undiscovered facades report a module that is not where it looks: a pillar file on disk that resolves to nothing, and a facade PHP cannot load or that does not extend AbstractFacade
  • unusable #[Provides] reports a method that cannot supply what it promises: non-public (the scanner reads public methods only), a required parameter (ArgumentCountError), or a void/never return (the id answers null). An optional parameter is not a fault
  • duplicate provided id reports one id declared twice on the same Provider: the last method wins, every one before it is dead, and all of them read as live
  • cacheable storage reports #[Cacheable] on the default backend, which dies with the process, so under PHP-FPM an hour's TTL is recomputed every request
  • More inert declarations: an extendService() id no Provider set()s, a listener target no dispatched event can be, an addAppConfig() path matching no file, a tagged id nothing can answer, listeners registered under disableEventListeners(), an unwritable cache directory, a namespace a package's composer.json never mentions, and stale editor metadata
  • Print only the checks that found something with doctor --only-problems. Nineteen checks is a lot of "✓" to read to find the one "⚠", and --strict -q fails a build without saying what failed

Static analysis

  • gacela.cacheableWithoutCachedCall / GacelaCacheableWithoutCachedCall: a #[Cacheable] method that never reaches $this->cached() is simply not cached, the attribute being metadata cached() reads. On by default, and judges the class, since cached() may live in a documented private helper
  • gacela.cacheableKeyIgnoresArguments / GacelaCacheableKeyIgnoresArguments: a key with no {N} placeholder is the same string for every call, so getUser(2) is answered with user 1's row. On by default
  • gacela.serviceMapMissing / GacelaServiceMapMissing finds the pillar accessors 3.0 removes, naming the #[ServiceMap] to paste. Off by default, since a @method accessor is not wrong on 2.x

JSON output

  • debug:dependencies, debug:container and debug:modules share one vocabulary: parameters and tree mean the same in all three, processMemoryBytes is a number rather than 10 MB, an empty binding map stays {}, total counts distinct classes while tree repeats one two parents ask for, and summary counts faults apart from unresolvable
  • doctor carries each check's name, status, details and remediation under an overall status, narrowed by --only-problems; debug:config marks every key declared, undeclared or missing; list:modules writes null for a pillar a module does not have; validate:config also gained --strict, failing on warnings as doctor does
  • Every refusal answers with a document too, and --json alone is never a gate — only --check turns a verdict into an exit code

Commands

  • debug:modules --check fails on an unsatisfiable pillar. Only faults fail it: a union or intersection type is a gap in the inspector, not the project
  • debug:provides finds which Provider declares an id, keeping both rows when two modules declare one; debug:module lists a module's own under Provides (#[Provides]). Attribute-declared ids only — finding the imperative set()s means running the Provider
  • profile:report names what it never saw finish: a stop() misspelling its operation vanished and looked like code nobody instrumented. --format=json carries the same answer in an unfinished field
  • --dry-run previews what make:module, make:file and stubs:publish would write, saying "would replace" apart from "would create"
  • ide:meta generates editor metadata for getProvidedDependency(); an id two Providers type differently is listed rather than written

Declarations

  • declareDtoSchema() declares a data shape and dto:generate writes the immutable class: typed getters, with*() copies, toArray() and fromArray(). Declarations of one shape union, so a project extends a packaged shape without forking it, and --check writes nothing, names each stale file and exits non-zero
  • addPluginStack() declares an extension point: every implementation of one interface, in declaration order, resolved lazily and read back typed through getPluginStack()
  • addResolvableType() declares a class kind of your own, resolved by suffix like the four pillars and reached through DeclaredTypeResolverAwareTrait. The addSuffixType*() verbs are now sugar over it, and make:file App/Wallet Exporter generates one
  • addConfigDimension() selects configuration by more than APP_ENV, so config/app-prod-eu.php refines config/app-prod.php. The merged-config cache is keyed by the whole tuple
  • extendProviderService() wraps a service as one Provider registers it, leaving every other module on the same id alone
  • GacelaConfig::setEventDispatcher() sets a custom dispatcher, which takes precedence over disableEventListeners()

Changed

  • Refuse three things that used to be accepted silently. This changes existing behaviour in all three. An output format the command does not have, in doctor, validate:config, debug:config and profile:report, plus an unknown profile:report --sort: --format=jsno printed the text report and exited 0. A make:file kind Gacela does not have, instead of generating the closest pillar — Repository wrote a Factory — refused with the addResolvableType() call that would make it real. And an overwrite in make:module or make:file without --force, every target checked before the first is written
  • Accept --json on every command that can emit it; --format still chooses among the commands offering a real choice
  • Point a "cannot resolve" error at the file already in the module directory — one whose namespace does not match, or one extending the right base under a name no finder rule builds — and name it in the "module has no Factory" error too. Read from the file's tokens, not name similarity, which would offer WalletFacade as the missing Provider
  • Skip loading a file where no class extends anything, and ask whether a class is a Facade before building a module out of it: 454 of 1627 classes here never load, and 1292 constructions become the 121 kept. About 13 ms off a median discovery run each
  • Memoize the resolvable-type normalization in DocBlockResolver: service resolution 10–16% faster on all four DocBlockResolverBench subjects, cleared by Gacela::resetCache()
  • Resolve a class name to its kind through the configured suffixes rather than the four literal pillar names, so overrideExistingResolvedClass() takes effect under addSuffixTypeFacade('PublicApi')
  • Say what an empty module listing means, in list:modules, debug:graph and debug:modules alike: a Facade whose namespace composer cannot map
  • Name the class in the docblock-fallback deprecation as \Fully\Qualified\Name::class, so the #[ServiceMap] line it suggests pastes into any namespace unchanged

Fixed

Resolution

  • Stop #[Inject] hiding a parameter the container cannot satisfy. It short-circuited the inspection, so debug:modules --check exited 0 for a module that throws DependencyNotFoundException when built; it now labels the answer rather than being it, and a named class that does not exist is a fault
  • Stop matching a pillar by resemblance: a class is one because it is one, not because its name contains the word (a ConfigReader service was returned as the module's Config, ignoring an explicit #[ServiceMap]); a short name resolves against the name an import defines, not the end of the use line, so a facade called exactly Facade no longer matches any neighbouring *Facade import; and an accessor is found by its @method tag, not the first docblock line naming it
  • Read gacela.php once when bootstrapping without a closure. Everything declared arrived twice: one addAppConfig() globbed twice, a plugin declared once ran twice
  • Refuse a redefined DTO property when one source declares the same class twice, not only when two sources meet. An identical redeclaration stays allowed

Caches

  • Keep cache files inside the directory meant for them. '', '/' and whitespace all normalized to nothing, so FileCache wrote '/<sha1>.php' and ScopedCache persisted its graph to /; APP_ENV is now held to a declared dimension's alphabet, since APP_ENV=../escaped wrote the merged-config cache outside its directory and APP_ENV=x/../../pwned failed silently and booted uncached
  • Let the cache directory agree with itself: normalized on every getCacheDir() call rather than the first, and the class-name cache keyed by the bootstrap's projectNamespaces and suffix types, so two bootstraps sharing a directory cannot serve each other's classes
  • Warm the accessors cache:warm --attributes documents, without a deprecation per facade. It resolved real methods, which __call() never reaches, so every entry it wrote was one nothing could look up
  • Preload the framework and its packages by discovery rather than a hand-written list, which linked none of the pillars, the resolvers or either container

Commands

  • Generate a gacela.php naming the project's own namespaces, read from composer.json, and create config/app.php beside it. init wrote the literal setProjectNamespaces(['App']), costing a failed lookup on every cold resolution and resolving the wrong class for a project that has an App\ module
  • Write an array default as one readable expression in dto:generate, debug:dependencies and debug:modules; var_export() put array ( on its own line, mangling generated code and a JSON detail field
  • Refuse debug:graph --rules and --allowed-cycles without --check: the run was a gate that looked green while checking nothing
  • Refuse make:module --with-tests on a template that scaffolds no test, and any make:* path whose segments cannot be PHP names, before writing anything. Accented and non-latin names are still accepted
  • List the autoload-dev prefixes too when make:* cannot match a namespace: the lookup searches both, and Known PSR-4: was built from autoload alone
  • Stop validate:config ticking a binding it just warned about, and debug:container calling a container empty while reporting what is in it
  • Say whose bindings debug:module lists — Application bindings (project-wide) — since it reports the same list whichever module is named
  • Emit a document from profile:report --format=json on the two runs that report nothing, and round the per-operation total_duration in Profiler::getStats() like every other duration there
  • Report an extendProviderService() id the named Provider never set()s, as doctor's own docblock promised
  • Report a #[Cacheable] key whose placeholders all point past the arguments; one in range is enough, and a variadic is not judged on the index

Error messages

  • Name the module and the file when an undeclared Factory or Config is called, instead of Call to undefined method …@anonymous::createThing()
  • Point the resolver's advice at something that exists: the kind that failed, the module-prefixed class in its E.g. line, the class-not-found tips on both exceptions naming a class, and the namespace ServiceMap actually lives in
  • Name what was meant when configuration goes wrong: the file in "must return a callable(GacelaConfig)" and in "The PHP config file must return an array…", which always said gacela.php and so sent you away from a broken gacela-prod.php, and the available keys from all six typed getters
  • Report an --allowed-cycles file's shape before judging its entries, saying either to wrap a stray entry in [ ] or that --rules is the flag taking an object
  • Say a plugin's class does not exist, instead of reporting it as one that "does not implement" the contract
  • Throw GacelaNotBootstrappedException from Config::getInstance(), as Gacela::container() already did, so catching the typed exception no longer misses the commonest case
  • Name the cause when assertServiceResolved() has no events to read: only bootstrapGacela() registers the collecting listener

Static analysis

  • Stop the rules reporting the impossible: a Facade may delegate to a kind declared with addResolvableType(), which FacadeOnlyDelegates did not know about; gacela.suffixExtends no longer flags a class that already extends something else; and a Facade's static methods are no longer judged on delegation or interface sync, having no $this
  • Reattach nine orphaned docblocks in the shipped code, left describing nothing when a method was added between a docblock and its signature. A test now fails on any docblock whose next meaningful token is another docblock

Bridges

  • Ship both bridges complete: dto:generate, ide:meta and stubs:publish are registered in each, psr/container is declared in both manifests and symfony/console in Laravel's, and test-only requirements are demoted to require-dev. A test in each bridge now reads the command directory and fails when the list falls behind
  • Answer a mistyped Laravel bridge config key with the one that was meant, as Symfony's config tree does

Internal

  • Clear PHPStan's result cache before the fixture tests analyse. It is keyed on the analysed files, not the rules judging them, so a rule that had stopped reporting still looked green. The clear is checked, the first attempt having passed an unsupported option and been swallowed
  • Drive every default-on PHPStan rule from a test. Three of five had unit tests for the analyser and nothing reaching the rule itself, and a rule whose adaptation is broken analyses clean
  • Give the method-level static-analysis rules the MethodAnalyserInterface the class-level ones have, so both hosts adapt them as a list instead of once per rule
  • Move the bridge bootstrapping into Gacela\Framework\Bootstrap\IntegrationBootstrapper. Both bridges held a byte-identical copy, including a resetInMemoryCache() call each had to learn separately; the host arrives through a closure, so this adds no dependency
  • List the commands once in CommandCatalog rather than once per registry. The same nineteen were written out in ConsoleProvider and in each bridge, and three had already been missed
  • Delete ContextualBindingRegistrar, a pass-through since container 2.0 shipped give(null) (container#169)

Documentation

  • Correct the remedy for a misspelled provided-dependency id: debug:module lists what #[Provides] declares, not what a Provider registers imperatively, so the module the advice was written for prints (none)
  • Document setAppModulePaths(), which decides how much of a project every CLI command walks: 14,290 filesystem entries against 1,826 when narrowed, same modules found
  • Document the Symfony bridge's enabled key, asserted by a test against the README