feat(rules): ForbidAdHocDateParsingRule — decode a date once, at the boundary (ADR-0020 Amd 1) - #71
feat(rules): ForbidAdHocDateParsingRule — decode a date once, at the boundary (ADR-0020 Amd 1)#71Goosterhof wants to merge 8 commits into
Conversation
…(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.
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>
There was a problem hiding this comment.
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.
…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
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
…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
… into feat/forbid-ad-hoc-date-parsing-rule
… 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
left a comment
There was a problem hiding this comment.
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:306 — see inline
Named timezone arguments are treated as date strings by firstArgumentMayBeAString()
src/Rules/ForbidAdHocDateParsingRule.php:342 — see inline
Uppercase static parser calls bypass PARSING_METHODS despite PHP method case-insensitivity
src/Rules/ForbidAdHocDateParsingRule.php:258 — see inline
Raw namespace-prefix matching exempts siblings outside dateParsingNamespaces
src/Rules/ForbidAdHocDateParsingRule.php:239 — see 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()); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
|
Round 3 landed at 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. |
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.
ForbidAdHocDateParsingRuleflags, outside the configureddateParsingNamespaces(defaultApp\Support\Time,App\Support\DateTime,App\Casts): staticparse/rawParse/createFromFormat/createFrom*/create/makeon anything resolving toDateTimeInterface(Carbon, CarbonImmutable,Illuminate\Support\Carbon, theDatefacade),new DateTime(...)/new DateTimeImmutable(...), andstrtotime/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/?stringfire. Type-aware: an aliased Carbon fires, a local class namedCarbonthat is not aDateTimeInterfacedoes 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::parseand 20 oncreateFromFormatwith 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
composer format:checkcomposer phpstan(21 services, level max)composer testcomposer coverage:checkcomposer mutation:cimb_*→ASCII equivalents inshortName()/processFuncCall()on identifiers that are ASCII by constructionphp8.4 vendor/bin/phpunit+ phpstan)--no-devproduction-tree analysis (stub-unreachability control fired first)illuminate/* ^12legcomposer auditleague/commonmarkonly, pre-existing (WR-1256), halted, not bumpedpr-size-gate.pyTeeth: 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_formatfunctions 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-Carbonfixture; a configuration test provesdateParsingNamespacesis read. Fixture line numbers re-asserted after Pint (global_namespace_importshifted four by one).Two things the tree taught the orders: no analysis anchor was needed (Carbon and
Dateare production dependencies throughilluminate/database, verified on the--no-devtree), andleague/commonmarkis a production dependency here viailluminate/mail, not dev-only.Round 1 (2026-09-07, dmooibroek's harness — two Medium, both confirmed, 76b68be)
create()delegates toparse()when$yearis a non-numeric string, socreateFromDate($raw)is a real decode. Fix is onefirstArgumentMayBeAString()gate in front of all three shapes, direction fixtured (mixedfires). Infection then showed the gate masked the method-name check, so the clock fixture gained a non-listed static helper handed a string.date_create_from_format/date_create_immutable_from_formatescaped — 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 plantedthrownever fired), so the guard was dead code no test could pin; the fixture line stays as the tripwire.CI is red on
composer auditonly —league/commonmark2.9.0 (pre-existing, WR-1256). #72 bumps it and is green; this PR goes green once #72 merges andmainis 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