Skip to content

feat(rules): ForbidAdHocDateParsingRule — decode a date once, at the boundary (ADR-0020 Amd 1) - #71

Open
Goosterhof wants to merge 8 commits into
mainfrom
feat/forbid-ad-hoc-date-parsing-rule
Open

feat(rules): ForbidAdHocDateParsingRule — decode a date once, at the boundary (ADR-0020 Amd 1)#71
Goosterhof wants to merge 8 commits into
mainfrom
feat/forbid-ad-hoc-date-parsing-rule

Conversation

@Goosterhof

@Goosterhof Goosterhof commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

What

One new Level 2 rule — enforcement queue #222, ADR-0020 Amendment 1 (Semantic Boundary Types): date/time construction from a string is reported outside the namespaces where the boundary decode is allowed to live.

ForbidAdHocDateParsingRule flags, outside the configured dateParsingNamespaces (default App\Support\Time, App\Support\DateTime, App\Casts): static parse/rawParse/createFromFormat/createFrom*/create/make on anything resolving to DateTimeInterface (Carbon, CarbonImmutable, Illuminate\Support\Carbon, the Date facade), new DateTime(...) / new DateTimeImmutable(...), and strtotime/date_create*/date_parse*each only when the first argument is present and not provably non-string (round 1). Integer components, an existing value object, null, zero-argument clock reads (create(), date_create(), new CarbonImmutable(null, $tz)), now()/today(), timestamp factories and instance methods are silent; mixed / int|string / ?string fire. Type-aware: an aliased Carbon fires, a local class named Carbon that is not a DateTimeInterface does not.

Seed: 30 of 354 crit findings in one week were date/cursor-bound semantics re-implemented per Action (lokalekeuze #190: seven rounds on one parser); emmie has 77 files on CarbonImmutable::parse and 20 on createFromFormat with a strict helper used by four. Adoption is a per-territory bump on its own PR with a baseline — nothing here touches a consumer. Versioning: MINOR, not tagged.

Verification

Gate Exit
composer format:check 0
composer phpstan (21 services, level max) 0
composer test 0 — 276 tests / 712 assertions
composer coverage:check 0 — 91.38 % (floor 83)
composer mutation:ci 0 — MSI 86 % on 1482 mutants (floor 75); rule-only pass 61/65 killed, the 4 escapes being the method-name mask (now fixtured, see round 1) and three mb_*→ASCII equivalents in shortName()/processFuncCall() on identifiers that are ASCII by construction
PHP 8.4 leg (php8.4 vendor/bin/phpunit + phpstan) 0 / 0
--no-dev production-tree analysis (stub-unreachability control fired first) 0
illuminate/* ^12 leg 0
composer audit 1 — league/commonmark only, pre-existing (WR-1256), halted, not bumped
pr-size-gate.py PASS (lines=1067 files=20 at round 0; +262/−49 over 9 files in round 1; limits 2000/30)

Teeth: disabling the namespace gate reds 4 tests; removing the argument gate reds exactly the component-factory fixture; flipping the gate to demand a proven string reds exactly the direction fixture; dropping the two *_from_format functions reds the denominator; removing the method-name check reds exactly the clock fixture; swapping the type check for a name match reds exactly the local-class-named-Carbon fixture; a configuration test proves dateParsingNamespaces is read. Fixture line numbers re-asserted after Pint (global_namespace_import shifted four by one).

Two things the tree taught the orders: no analysis anchor was needed (Carbon and Date are production dependencies through illuminate/database, verified on the --no-dev tree), and league/commonmark is a production dependency here via illuminate/mail, not dev-only.

Round 1 (2026-09-07, dmooibroek's harness — two Medium, both confirmed, 76b68be)

  1. Static factories fired on integer components and on the zero-argument clock read while the list's docblock claimed "every entry decodes". Not a list edit: Carbon's create() delegates to parse() when $year is a non-numeric string, so createFromDate($raw) is a real decode. Fix is one firstArgumentMayBeAString() gate in front of all three shapes, direction fixtured (mixed fires). Infection then showed the gate masked the method-name check, so the clock fixture gained a non-listed static helper handed a string.
  2. date_create_from_format / date_create_immutable_from_format escaped — added, denominator lines added.

One thing written and removed: a first-class-callable guard (Carbon::parse(...)). PHPStan never delivers that node to the rule (a planted throw never fired), so the guard was dead code no test could pin; the fixture line stays as the tripwire.

CI is red on composer audit onlyleague/commonmark 2.9.0 (pre-existing, WR-1256). #72 bumps it and is green; this PR goes green once #72 merges and main is merged in.

Orders: war-room orders/phpstan-warroom-rules/2026-09-07-forbid-ad-hoc-date-parsing-rule-armorer-deployment.md. Report: reports/phpstan-warroom-rules/execution/2026-09-07-armorer-forbid-ad-hoc-date-parsing-rule.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SCkPAHkZSXXR3c8WuCaJnq

…(queue #222, ADR-0020 Amd 1)

Adds one PHPStan rule reporting date/time construction from a STRING anywhere
outside the namespaces where the boundary decode is allowed to live.

The failure this closes is not a parse that throws. It is a parse that SUCCEEDS
in fifty places with fifty slightly different readings of the same string — a
month-only bound, a `24:00` end-of-day, a bare `T`, an IANA zone the next Action
drops, a day treated as an instant — each locally defensible, with the
disagreement surfacing only in a report that does not balance. Measured seed: 30
of 354 crit findings in one week were date/cursor-bound semantics; lokalekeuze
PR #190 drew seven review rounds on one parser. emmie has 77 files calling
`CarbonImmutable::parse(` against one strict helper used by four.

Three call shapes, one `CallLike` registration: a static call whose class
RESOLVES to a `DateTimeInterface` subtype or to the `Date` facade with a method
in the 13-entry parse/create set; `new` on a `DateTimeInterface` subtype WITH at
least one argument; and the five procedural `strtotime` / `date_create*` /
`date_parse*` functions.

One anchor, not two. `Carbon\CarbonInterface` extends `DateTimeInterface`, so a
single supertype check covers the whole Carbon family plus the two PHP natives.
A second Carbon-specific condition would be redundant and unfalsifiable — no
input could distinguish them — so the inheritance is ASSERTED in the suite
rather than claimed in a docblock, because it is a fact about an upstream
package that could change with every fixture still green.

Resolution is by TYPE, never by the written token: an aliased
`use Carbon\CarbonImmutable as C;` fires and reports under the real class; a
local class merely NAMED `Carbon` does not.

Deliberate misses, all for the same reason — nothing there decodes a string:
zero-argument construction (that is "now"), the now/today/yesterday/tomorrow
clock reads, the `createFromTimestamp*` family plus `instance` and
`fromSerialized`, instance calls on an already-decoded value, and dynamic class
/ method / function expressions, where the receiver has no resolvable name.

The boundary is configuration. `dateParsingNamespaces` (new `extension.neon`
parameter, `str_starts_with` matching) defaults to `App\Support\Time`,
`App\Support\DateTime` and `App\Casts` — the two doors ADR-0020 Amd 1 names: a
dedicated decode helper and the row-to-model cast. A class in the global
namespace is outside every prefix and therefore fires.

Teeth, both directions. Six shape fixtures assert an exact message at an exact
line; five assert `[]`. Three probes each reddened exactly the assertion that
pins it, then restored: disabling the namespace gate reds both boundary fixtures;
removing the zero-argument carve-out reds the clock fixture; replacing the type
check with a name match reds the local-`Carbon` fixture. The namespace probe was
re-run AFTER Pint to confirm the formatter had not hollowed the gate out.
`AllParsingMethods.php` names every list entry exactly once and asserts 21 errors
at 21 named lines, so a dropped entry fails by line rather than silently
narrowing the rule. Configuration is proved in both directions plus a control.
`extension.neon` wiring is pinned by resolving the rule from the PHPStan
container, exercising the shipped NEON default rather than the constructor one.

Gates: format:check, phpstan, test (274), test:coverage + coverage:check (91.34%
vs floor 83; the new rule at 100% of 54 lines), mutation:ci (MSI 86.01% vs floor
75) — all green on PHP 8.5 and 8.4. Both secondary CI legs replicated in
throwaway trees: `--no-dev` production-tree analysis passes WITH its
stub-unreachability control firing (Carbon and the Date facade arrive
transitively through `illuminate/database`, so no `stubs/analysis-anchors.php`
entry is needed), and the `illuminate/* ^12` leg passes phpstan + 274 tests with
the resolved major asserted. `composer audit` is red on `league/commonmark` only
— pre-existing, lockfile untouched, WR-1256.

Versioning: MINOR. NOT tagged.

Also corrects README § Versioning, which still told consumers to pin `^0.2`
five minors after that stopped being the current one.
@Goosterhof
Goosterhof requested a review from a team as a code owner September 7, 2026 11:04
@Goosterhof Goosterhof added the Agent Review Requested Requesting review of specialized AI review agents. label Sep 7, 2026
HIGH DoS advisory PKSA-zyf5-hrxv-hrd7, affected >=1.5.0,<2.10.0. commonmark is a PRODUCTION dependency here (illuminate/mail in require), so the audit-gated release lane was blocked. Lockfile-only. WR-1256.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@dmooibroek dmooibroek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ForbidAdHocDateParsingRule (ADR-0020 Amd 1) has two coverage gaps: static component factories (create/createFromDate/etc.) fire on plain int args and even on the exempt zero-arg clock-read, while the procedural PARSING_FUNCTIONS list omits date_create_from_format/date_create_immutable_from_format, leaving an easy escape hatch.

Comment thread src/Rules/ForbidAdHocDateParsingRule.php
Comment thread src/Rules/ForbidAdHocDateParsingRule.php
…he two `*_from_format` procedural aliases

Review round 1 (dmooibroek's harness, two Medium findings), both confirmed at HEAD:

1. The static-call path had no argument gate, so `Carbon::create(2026, 9, 7)`,
   `createFromDate(...)` with integer components, `make($carbon)` (a re-wrap) and
   the zero-argument clock read `Carbon::create()` all fired while the list's own
   docblock claimed "every entry decodes". Carbon's `create()` delegates to
   `parse()` exactly when `$year` is a non-numeric string, so the names cannot
   discriminate — the ARGUMENT can. `firstArgumentMayBeAString()` now gates all
   three shapes: silent when the first argument is absent or provably non-string,
   fires otherwise. Direction is deliberate and fixtured: `mixed`, `int|string`
   and `?string` fire, because an untyped value at a parse site is exactly what
   the boundary type exists to pin down. `processNew`'s zero-argument check is
   the same gate, so `new CarbonImmutable(null, $tz)` stops firing too.

2. `date_create_from_format` / `date_create_immutable_from_format` — PHP's
   procedural aliases of `createFromFormat` — were missing from PARSING_FUNCTIONS,
   a one-token escape hatch around the method the seed measured as the
   second-largest offender. Added, with denominator fixture lines.

Teeth (each reds exactly one test, restored): gate removed → component-factory
fixture; gate flipped to demand a PROVEN string → direction fixture; the two
aliases dropped → denominator; method-name check removed → clock fixture (a
non-listed static helper handed a string was added there, because the new gate
would otherwise mask the name check — infection found that one). A
first-class-callable guard was written and REMOVED: PHPStan never delivers
`Carbon::parse(...)` to the rule (a planted throw never fired), so the guard was
dead code no test could pin; the fixture line stays as the tripwire.

Docblocks, README, CLAUDE.md and CHANGELOG reworded to describe the gate rather
than an argument count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018h5J5eXwETkbjc7wSAX9cy

@dmooibroek dmooibroek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR adds ForbidAdHocDateParsingRule to gate ad-hoc date parsing at the boundary; both prior medium findings are fixed, but the new argument gate calls getArgs() on nodes that may be first-class callables and can crash under zend.assertions.

Resolved since last review: 2.

*/
private function firstArgumentMayBeAString(CallLike $node, Scope $scope): bool
{
$args = $node->getArgs();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] getArgs() on first-class callables can crash the rule

firstArgumentMayBeAString() now gates StaticCall and FuncCall (new in this PR) and calls $node->getArgs(), but CallLike::getArgs() in nikic/php-parser ^5 asserts !isFirstClassCallable(). For CarbonImmutable::parse(...) the raw args are a VariadicPlaceholder: with zend.assertions=1 (PHP CLI default) this is an AssertionError/PHPStan internal error; with assertions off, $args[0]->value reads an undefined property (null) and $scope->getType(null) TypeErrors. Before this PR only New_ (never a first-class callable) reached getArgs(); the docblock claims PHPStan never delivers such nodes and the fixture doesn't pin the assertion path. Fix: add if ($node->isFirstClassCallable()) { return false; } at the top of the gate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed at b8f014f, though not with the guard you propose — the hazard is real and the exposure is not, and the difference is now pinned. CallLike::getArgs() does assert !isFirstClassCallable() (php-parser CallLike.php:32). But PHPStan never hands this rule such a node: it substitutes StaticMethodCallableNode / FunctionCallableNode / MethodCallableNode for every first-class callable, and none of those extends CallLike, so a rule registered on CallLike cannot receive one. Probed rather than assumed: a node logger over the fixture recorded 18 delivered CallLike nodes and zero first-class callables, an unconditional throw at the choke point proved the instrument live, and zend.assertions is -1 on this station so the earlier green had never exercised the assertion at all.

A guard would be unreachable code that every mutation escapes (min-msi=75 here). Instead testPhpstanFirstClassCallableNodesAreNotCallLike asserts the three upstream classes exist and are not CallLike — the release that changed that would go red with the guard-the-choke-point remedy in the failure text — and the fixture tripwire now carries both the static and the function shape. Docblock corrected: the old one claimed PHPStan 'does not deliver' such nodes without saying why.

Goosterhof and others added 3 commits September 7, 2026 19:51
…eleased

crit round 1 on #72: the lock moved without a changelog line. Dev-only
dependency, but the pin was reddening every CI job's composer audit (WR-1256).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JMk9fRGSpbtAdYfFSSToP5
… reaches getArgs(), and now something says so (round 2)

Reviewer finding on #71: `firstArgumentMayBeAString()` calls `CallLike::getArgs()`,
which asserts `!isFirstClassCallable()`, so `CarbonImmutable::parse(...)` would be an
AssertionError under `zend.assertions=1` and an undefined-property read without it.

Measured, and REFUTED as a live defect. PHPStan substitutes `StaticMethodCallableNode`
/ `FunctionCallableNode` / `MethodCallableNode` for a first-class callable; all three
extend `PhpParser\Node\Expr` directly and none is a `CallLike`, so a rule registered on
`CallLike` structurally cannot receive one. A node logger planted at `processNode`
recorded 18 delivered CallLike nodes, zero of them first-class callables, with a
positive control at the same fixture lines proving the region was analysed. No guard
is added: it would be unreachable code no test could pin, and infection mutates `src`,
so every mutation of it would escape.

What the round ships instead is the falsifiable half the finding was right to want:

- Assert the upstream fact in the suite, mirroring `testCarbonInterfaceExtendsTheDateTimeAnchor`.
  A PHPStan release that made any of the three nodes a `CallLike` now reds the build with
  a message naming the remedy, instead of handing the rule a shape it crashes on.
- Extend the fixture tripwire from one shape to both the rule registers for — round 1
  pinned only the static call, leaving `FunctionCallableNode` (a distinct PHPStan node)
  unpinned.
- Correct the rule docblock, which claimed PHPStan "does not deliver the node" without
  naming the mechanism, and narrated a probe rather than stating the rule that holds.

Gates green on PHP 8.4.18 and 8.5.4: pint, phpstan (level max), phpunit 279/279 (up from
276 by exactly the three new data cases), coverage 91.38% vs 83 threshold, infection MSI
86% vs 75. Suite run under `-d zend.assertions=1` throughout; the station default is -1.

Carries the #72 commonmark 2.10.0 bump so this branch's `composer install` audit is honest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JMk9fRGSpbtAdYfFSSToP5

@jasperboerhof jasperboerhof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crit review

4 issues · 1 nitpick · head b8f014f909

Crit requests changes — 4 issues.

Issues

Function aliases bypass PARSING_FUNCTIONS while same-namespace helpers trigger false errors
src/Rules/ForbidAdHocDateParsingRule.php:306see inline

Named timezone arguments are treated as date strings by firstArgumentMayBeAString()
src/Rules/ForbidAdHocDateParsingRule.php:342see inline

Uppercase static parser calls bypass PARSING_METHODS despite PHP method case-insensitivity
src/Rules/ForbidAdHocDateParsingRule.php:258see inline

Raw namespace-prefix matching exempts siblings outside dateParsingNamespaces
src/Rules/ForbidAdHocDateParsingRule.php:239see inline

1 nitpick

Named Carbon create arguments can hide strings passed to the year parsing input
src/Rules/ForbidAdHocDateParsingRule.php:342 — firstArgumentMayBeAString() inspects only the first source-order argument. CarbonImmutable::create(month: 1, year: $raw) can pass a nonnumeric string to parse(). The integer month suppresses the diagnostic. A caller can decode a string outside the boundary without an error.

nitpick because code change required — the harm needs an edit that has not happened

return [];
}

$function = mb_strtolower($node->name->toString());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function aliases bypass PARSING_FUNCTIONS while same-namespace helpers trigger false errors

processFuncCall() compares the written FuncCall name against PARSING_FUNCTIONS. PHP function aliases retain their local name in that node. A caller can invoke strtotime through an alias without a diagnostic. A same-namespace helper named strtotime triggers an unrelated error.

crit · finding fe9b246f3acd

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken at 4ca045b, half of it. The helper half was real: a same-namespace strtotime() fired as the global — the callee is now resolved through the injected ReflectionProvider (hasFunction + getFunction()->getName()), so only the real global matches. The alias half is refuted: php-parser resolves use function strtotime as decode into the node, and the fixture line already fired at the pre-fix head; it stays as a regression pin. Variable functions remain out of scope, said so in the docblock.

return false;
}

return !$scope->getType($args[0]->value)->isString()->no();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named timezone arguments are treated as date strings by firstArgumentMayBeAString()

firstArgumentMayBeAString() reads the first source-order argument without its argument name. CarbonImmutable::create(timezone: 'Europe/Amsterdam') provides no date input. That call creates a clock value but produces a boundary-parsing error.

crit · finding 4dac691044cc

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken at 4ca045b, and wider than the finding: the decoded slot is now read by parameter name first, position second, per verb — because argument zero is the format string for five of the thirteen methods and three of the seven functions (createFromFormat decodes slot 1, createFromLocaleFormat slot 2), and Carbon spells the slot $time where the PHP natives spell $datetime, so a slot accepts a name set. Four fixtures pin it: create(timezone: …) silent, create(month: 1, year: $raw) fires (the nitpick), date_create_from_format(format: …, datetime: $raw) fires, and a non-string named slot is silent. Slot names are asserted against Carbon's and PHP's own signatures in a test, teeth-proved against a renamed slot and a shifted position.


$method = $node->name->toString();

if (!in_array($method, self::PARSING_METHODS, true)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uppercase static parser calls bypass PARSING_METHODS despite PHP method case-insensitivity

processStaticCall() preserves the static method identifier's written casing. PHP dispatches PARSE() to the same method as parse(). A caller can decode a string outside the boundary without a diagnostic.

crit · finding f2aad3bb58db

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken at 4ca045b: mb_strtolower on the identifier at the comparison site and lower-cased map keys — PARSE() and CREATEFROMFORMAT() were both silent at the pre-fix head (the earlier round had not folded either side, contrary to what I told the Medic). The same fold is absent package-wide and is fail-open on ForbidCredentialCastBypassRule — filed as WR-1291.

}

foreach ($this->dateParsingNamespaces as $prefix) {
if (str_starts_with($namespace, $prefix)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raw namespace-prefix matching exempts siblings outside dateParsingNamespaces

insideBoundaryNamespace() uses str_starts_with() for every configured prefix. App\CastsReport matches the App\Casts prefix without being its sub-namespace. Consumers can decode strings in unrelated namespaces without an error.

crit · finding e31995409085

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken at 4ca045b: the match is now $namespace === $prefix || str_starts_with($namespace, $prefix . '\\'), with a configured prefix's trailing backslash normalised once in the constructor; App\CastsReport fires, App\Casts\Nested stays exempt (control kept). Nine other rules in this package carry the same unbounded match against EnforceAuditModelProtectionsRule's correct precedent — WR-1291.

@jasperboerhof jasperboerhof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crit review

0 issues · 0 nitpicks · head 1d9c44a447

Crit requests changes — 4 open threads.

No new findings — the blockers are the threads below.

Still open

src/Rules/ForbidAdHocDateParsingRule.php — Unresolved function names flag local strtotime helpers as date parsers.
src/Rules/ForbidAdHocDateParsingRule.php — Reordered named arguments bypass the shared date-string gate.
src/Rules/ForbidAdHocDateParsingRule.php — already filed, still open
src/Rules/ForbidAdHocDateParsingRule.php — Prefix matching exempts sibling namespaces outside the configured boundary.

… decoded slot by name, fold the method, bound the namespace (round 3)

crit round 1 on PR #71 filed four coverage gaps in the rule. Each one is
answered here with a fixture line that was measured firing or silent at
`1d9c44a4` before any production edit, and each fix reverted afterwards to
confirm it reds only what it pins.

fe9b246f3acd — the function callee is RESOLVED through PHPStan's
ReflectionProvider instead of read off the written token. PHP resolves an
unqualified call inside a namespace to a same-namespace declaration when one
exists, so an `App\Support\strtotime()` helper matched the list without being
the global function (measured: it fired at HEAD). The finding's other half is
REFUTED: `use function strtotime as decode;` already fired, because
php-parser's NameResolver resolves function aliases into the node. The alias
fixture is kept as the regression pin.

4dac691044cc — the argument gate reads the DECODED SLOT, named per verb, by
parameter name first and position second (the reader
`ForbidCredentialCastBypassRule::argumentAt()` uses, for the same reason).
Both error directions were live at HEAD: `create(timezone: 'Europe/Amsterdam')`
fired on a call carrying no date input, and `create(month: 1, year: $raw)` was
silent on one carrying a string.

The order's premise that the slot is "the first parameter" for the
createFromFormat family is wrong against Carbon's real signatures:
`createFromFormat(format, time, timezone)` decodes its SECOND parameter and
`createFromLocaleFormat(format, locale, time, timezone)` its THIRD — argument
zero is the format string for five of the thirteen methods and three of the
seven functions. Carbon spells that parameter `$time` and the two PHP natives
spell it `$datetime`, on both createFromFormat and the constructor, so a slot
accepts a set of spellings. All of it is pinned against the real signatures by
testEveryDecodedSlotMatchesTheParameterItNames, in both directions — every
slot names a parameter that exists at that index, and every accepted spelling
is earned by a real signature — teeth-proved against a renamed slot and a
shifted position.

f2aad3bb58db — the static method identifier is folded before the lookup, since
PHP dispatches `PARSE()` and `parse()` to the same method. Confirmed, not
refuted: at HEAD neither side was folded and the list entries were camel-case.
The error still reports the casing the source wrote.

e31995409085 — the boundary prefix matches on a namespace SEPARATOR, so
`App\Casts\Money` is inside `App\Casts` and `App\CastsReport` is not. A
configured prefix's trailing backslash is normalised once. A sub-namespace
control fixture keeps a tighten-to-exact-match red rather than passing.

`extension.neon` needs no change: PHPStan's DI autowires the new
ReflectionProvider argument, and the container-resolution test is the control
on that.

Also corrects, on the merged #72 Security entry, the claim that
`composer install` audits the lock on every CI job — only the `check` job runs
an explicit `composer audit` step; `check-production-tree` installs `--no-dev`
and `check-lowest-laravel` runs `composer update`, and neither audits. And the
four now-false "first argument" descriptions of the gate in CHANGELOG.md,
README.md, CLAUDE.md and the rule docblock.

Gates, both PHP legs of the `check` job: format:check, phpstan, test (286),
coverage:check (91.40% vs 83.00%), mutation:ci (MSI 86.23% vs floor 75). The
four escaped mutants in this rule are all MBString mb_*-to-ASCII swaps, which
are equivalent on identifiers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JMk9fRGSpbtAdYfFSSToP5
@Goosterhof

Copy link
Copy Markdown
Contributor Author

Round 3 landed at 4ca045b — all four findings and the nitpick closed, replies inline. Also folded in: the ### Security changelog sentence from the merged #72's nitpick now names the check job's explicit audit step rather than claiming every job audits. Gates on both PHP 8.4 and 8.5: format 0, phpstan 0, tests 286, coverage 91.4 % vs 83, MSI 86.2 vs 75 (four escaped mutants, all mb_* → ASCII swaps that are equivalent on identifiers).

Package-wide sibling of two of these findings filed as WR-1291 (nine rules with the unbounded prefix match; case folding absent everywhere, fail-open on the credential-cast rule). Size: this branch sits at 1847 of 2000 lines and 27 of 30 files — a round 4 of any size splits first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Agent Review Requested Requesting review of specialized AI review agents.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants