Skip to content

Fix: false-positive InvalidArgument on Nova field callbacks - #12

Merged
alies-dev merged 11 commits into
mainfrom
alies-dev/false-positive-invalidargument-argumenttypecoerc
Sep 2, 2026
Merged

Fix: false-positive InvalidArgument on Nova field callbacks#12
alies-dev merged 11 commits into
mainfrom
alies-dev/false-positive-invalidargument-argumenttypecoerc

Conversation

@alies-dev

@alies-dev alies-dev commented Sep 2, 2026

Copy link
Copy Markdown
Member

Context

Field visibility/display callbacks (showOnDetail, hideFromIndex, etc.), Filterable::filterable(), canSee(), and Stack::make() lines all typed their callback/array parameters at the widest possible type Nova's own loose signatures allow (array<array-key,mixed>|object, the contract Builder, NovaRequest|Request, class-string<Field>|callable). Since callback parameters are contravariant, Psalm rejected any narrower, runtime-correct closure signature a developer wrote by hand, e.g. fn(NovaRequest $request, Post $resource): bool for a Resource<Post>, even though Post is exactly what Nova passes at runtime. The only workarounds were widening every closure param back to mixed/object or reaching for @psalm-suppress.

Threading the resource's model generic through the callback via a method-hierarchy resolver (Psalm's MethodParamsProviderInterface) turned out to be a dead end: that provider only matches the concrete called class, and the Field hierarchy is open (user-defined fields), so it can't be enumerated ahead of time.

Fixes #6.

Solution

Four new stub files widen the accepted parameter shape without losing detection of genuinely wrong callback signatures:

  • stubs/Nova/Fields/FieldElement.phpstub — the six visibility setters (showOnIndex, showOnDetail, hideFromIndex, hideFromDetail, hideWhenUpdating, showOnUpdating) get a bounded @template TResource of Model|Fluent|array<array-key,mixed>|object on the callback's resource parameter, so both fn(NovaRequest, Post) and fn(NovaRequest, mixed) type-check, while a wrong request class, wrong return type, or extra required parameter still errors. Accepted trade-off, documented in the stub and in the README: since the bound admits stdClass, DateTimeImmutable, or an unrelated array shape too, a closure typed against the wrong model (or any type consistent with the bound) will also silently pass. This class also gets a narrow canSee(): Closure(NovaRequest):bool override, since Field resolution always goes through NovaRequest.
  • stubs/Nova/Fields/Filterable.phpstubfilterable()'s builder parameter uses a bounded @template TBuilder of Builder|Relation, since Nova passes a Relation (not a concrete Builder) for relationship-index requests, so both shapes type-check while stdClass and friends still error.
  • stubs/Nova/AuthorizedToSee.phpstub — left at its original wide NovaRequest|Request union. This trait is shared by Tool, Dashboard, Filters\Filter, and Menu\*, and Nova's BootTools middleware hands Tool::canSee() a plain Request, not a NovaRequest — narrowing it here would have reintroduced the same false-positive-turned-runtime-risk this PR is fixing, just relocated onto Tool/Dashboard authors. The narrowing that's actually safe lives on FieldElement instead (see above).
  • stubs/Nova/Fields/Stack.phpstub__construct (and make(), mirrored automatically by the existing NovaMakeSignatureHandler) accepts Field instances directly in the lines array, alongside class-string<Field>|callable.

Each stub's class/trait header replicates the real declaration's extends exactly, since Psalm resets a stubbed class's interface/trait data if the header doesn't match.

A new Psalm acceptance test (tests/AcceptanceTest.php + tests/fixtures/**) runs a minimal fixture project against a small hand-written fake Nova/Illuminate class set: scenarios/clean.php exercises the idiomatic shapes (including a Tool::canSee(fn(Request): bool) case and a Relation-typed filterable() case) and expects zero issues, scenarios/still_errors.php covers the cases that must still be rejected, including a Tool::canSee(fn(NovaRequest): bool) case proving the trait-level type wasn't narrowed. The fake Nova classes live in the fixture's own <stubs> config section rather than an autoloader, since Psalm scans config stubs before plugin stubs, letting the plugin's narrower stubs win as they would in a real project; an autoloader-based approach let the wide fakes load first and get overwritten backwards.

.github/workflows/tests.yml runs the new suite in CI, and psalm.yml scans **.phpstub paths so a bad stub trips the plugin's own self-check. Neither php-cs-fixer nor phpcs scans stubs/: several of their rules (forcing final class, disallowing mixed/empty method bodies) actively conflict with how a faithful Nova stub has to look, and getting them to pass would mean changing what the stubs assert rather than how they're formatted. .php-cs-fixer.php does gain one line excluding tests/fixtures from formatting, since the fake Nova fixture classes must keep mirroring the vendor source they stand in for and the scenario files' line numbers are asserted on in AcceptanceTest.php.

An earlier version of this PR narrowed filterable() to concrete Builder only and canSee() to NovaRequest trait-wide; external review caught that both were unsound for real Nova call paths (relationship-index filtering and Tool/Dashboard respectively), which is why the current shapes look the way they do.

https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R

Nova types four callback APIs wider than what it actually passes at runtime, so the
idiomatic narrow closure is rejected as InvalidArgument/ArgumentTypeCoercion:

- FieldElement's six visibility setters take the resource as Model|Fluent|object|array;
  a bounded method-level template lets Psalm infer it from the closure instead. Wrong
  request class, return type and arity are still reported; cross-model confusion is the
  accepted trade-off, since a Field cannot know its owning resource.
- Filterable::filterable() declares the query as the Eloquent Builder *contract* while
  always passing the concrete Builder.
- AuthorizedToSee::canSee() declares NovaRequest|Request but is only ever given a
  NovaRequest.
- Stack's lines omit Field itself, though the documented usage passes instantiated fields.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
Shells out to Psalm against tests/fixtures and asserts on its JSON report: the
idiomatic scenario must be issue-free, the broken one must still raise all three
genuine errors. Removing the four stub registrations makes the first test fail with
five issues, so the harness measures the fix rather than the fakes.

The fake Laravel/Nova declarations are registered as fixture <stubs>, not project
files: Psalm refuses stub files for class-likes inside <projectFiles>, and config
stubs are scanned before plugin stubs, which is what lets the plugin's stubs win the
override. They are also excluded from php-cs-fixer, which otherwise marks their
methods final and reformats the vendor docblocks under test.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
List Filterable, AuthorizedToSee and Stack alongside the existing stub
set, and fold the cross-model false-negative trade-off (a visibility
callback typed against the wrong model still type-checks) into the
README so a consumer of the plugin can find it without reading the
commit history or the FieldElement stub's docblock.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
still_errors.php only exercised Fix 1 (wrong request class/return
type) and Fix 3 (canSee). Fix 2 (Filterable) and Fix 4 (Stack) had no
negative coverage, so either could silently widen back to mixed
without CI noticing. Add one case per fix: a filter callback typed
with the wrong builder, and two Stack lines that are neither
class-string<Field>, callable, nor Field.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
@alies-dev alies-dev self-assigned this Sep 2, 2026
Nova's QueriesResources::newQuery() returns an Eloquent Relation (not a
concrete Builder) for relationship-index requests and passes it straight
into filterable()'s callback. Relation implements the query builder
contract but does not extend the concrete Builder class, so a closure
narrowed to Builder alone type-checks yet can receive a Relation at
runtime and throw a TypeError. Widen the callback's query parameter to a
bounded template over Builder|Relation, mirroring FieldElement's pattern,
so either concrete shape narrows correctly while a genuinely wrong type
is still rejected.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
…d regression #6

AuthorizedToSee is shared by Tool, Dashboard, Filters\Filter and Menu\*,
not just Field/Resource classes. Nova's BootTools middleware resolves
Tool::canSee() with a plain Illuminate\Http\Request, not a NovaRequest,
so narrowing the trait-level canSee() to NovaRequest made
Tool::canSee(fn(NovaRequest $r) => ...) type-check even though it can
receive a plain Request at runtime.

Revert the trait to Nova's original wide NovaRequest|Request union, and
redeclare a narrow canSee(): Closure(NovaRequest):bool on FieldElement
instead, since field resolution (ResolvesFields) provably always goes
through NovaRequest. Every Field subclass inherits the narrow override
through FieldElement without affecting Tool/Dashboard/Filter/Menu.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
The caveat understated what the bounded @template TResource trade-off
gives up: since the bound spans Model|Fluent|array<array-key,mixed>|object,
Psalm accepts any closure whose resource parameter is consistent with
that bound, not just one typed against the wrong model. A closure typed
stdClass, DateTimeImmutable, or an unrelated array shape type-checks too.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
- clean.php/still_errors.php gain a Relation-typed filterable() case,
  proving the bounded template accepts it alongside Builder.
- A new fake Tool class plus CleanTool/BrokenTool scenarios prove
  AuthorizedToSee stays sound: a plain-Request closure on Tool still
  type-checks, and a NovaRequest-only closure on Tool is now correctly
  rejected (ArgumentTypeCoercion) since the trait is no longer narrowed
  globally.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
php-cs-fixer's finder only matched *.php, phpcs.xml only scanned src,
and format_php.yml's push trigger didn't include **.phpstub, so the
"clean" cs-fixer/phpcs results from prior rounds never actually
inspected any stub file's formatting.

Bring .phpstub under php-cs-fixer's finder (they're plain PHP under a
different extension) and disable final_public_method_for_abstract_class
project-wide first: it only sees this repo's files, so it can't know
laravel/nova itself overrides several of the abstract methods our stubs
describe (e.g. Textarea overrides FieldElement::showOnIndex()) - marking
a stubbed vendor method final would reintroduce the exact false-positive
class this plugin exists to remove. No abstract classes exist in src/
today, so the rule change has no effect there. Applying the fixer then
only touches formatting (declare(strict_types=1), union-type spacing,
implicit-nullable-parameter deprecation cleanup in Action.phpstub).

phpcs.xml intentionally leaves stubs/ unscanned and documents why:
several Slevomat/IxDF sniffs (RequireAbstractOrFinal, DisallowMixedTypeHint,
DisallowEmptyFunction, InvalidNoReturn) would force changes that make the
stubs wrong rather than just reformatted (e.g. RequireAbstractOrFinal
would force `final class Panel`, but the real Laravel\Nova\Panel is
neither final nor abstract and is legitimately extended). format_php.yml
now triggers on **.phpstub regardless, matching psalm.yml, so a future
stub-only PR still runs php-cs-fixer.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
@alies-dev
alies-dev marked this pull request as ready for review September 2, 2026 13:13
…uded #6

Only the tests/fixtures exclusion in .php-cs-fixer.php is load-bearing:
without it, cs-fixer wants to reformat the fake Nova fixture files,
which would shift the line numbers AcceptanceTest.php asserts on.
Scanning .phpstub required disabling final_public_method_for_abstract_class
project-wide for no functional gain, since phpcs already excludes stubs/
for the same category of rule conflicts. Reverts that scope instead of
carrying the extra config surface.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
CleanTool asserted Tool::canSee(fn(Request)) is accepted, but that holds
by closure-param contravariance regardless of whether AuthorizedToSee
is narrowed, so it added no regression signal; BrokenTool in
still_errors.php already guards the real case. Also collapsed 6
near-identical visibility-setter calls in clean.php (all sharing the
same @template bound) down to 2, plus the existing bool-branch case.

Dropped declare(strict_types=1) from the 4 new stub files to match the
existing stub convention (bare <?php) — meaningless in a stub anyway,
since it's never executed.

Claude-Session: https://claude.ai/code/session_01FzaFiRNezfiiLYieQifR6R
@alies-dev
alies-dev merged commit 173fa03 into main Sep 2, 2026
6 checks passed
@alies-dev
alies-dev deleted the alies-dev/false-positive-invalidargument-argumenttypecoerc branch September 2, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

False positive InvalidArgument/ArgumentTypeCoercion on field callbacks typed with the concrete resource/builder/request

1 participant