Repository navigation
Phase 2: rule parity — 21 new rules, six dataset contracts, PHP ^8.5 - #5
Merged
Merged
Conversation
Phase 2 ships the Chrono rule family and the owner chose to target the 8.5 runtime for it rather than hold the ^8.4.1 floor. Manifest, all five workflow pins, the 13.x-dev canary matrix, README and installation doc move together — a floor changed without its matrix is how six repos sat red after the org's first adoption sweep.
CountryCode, LanguageCode and CurrencyCode validate against the CountryDataset/LanguageDataset/CurrencyDataset contracts, bound singletonIf to bundled full-registry defaults so an application (or laranail/atlas) replaces the data without touching the rules. The data is generated, not typed: tools/build-datasets.php reads committed snapshots of the official registries (ISO 4217 List One as published 2026-01-01 by SIX, the Library of Congress ISO 639-2 registry, and an ISO 3166 pairing cross-checked between two agreeing org sources) and a suite test runs --check so the outputs cannot drift from their sources. Both local candidates for the currency list were stale in different ways — the local ICU still carried BYR/MRO/STD/VEF, the atlas snapshot HRK/SLL — which is exactly the failure mode the generated registry snapshot removes; retired codes now fail and the 2016-2024 replacements pass, pinned by test.
Three archive parity rules, each corrected in the port. ISMN validates the current 13-digit ISO 10957 form through the shared GTIN-13 checksum (an ISMN is an EAN-13 in the 979-0 block) instead of int-casting whatever characters arrive. ASIN was 'any 10 alphanumerics' upstream; it is now the B-prefixed uppercase shape or a checksum-verified ISBN-10. UPC-E's legacy checksum ran over the 8 compressed digits, but the check digit belongs to the EXPANDED UPC-A — the rule now expands through the GS1 zero-suppression patterns and verifies GTIN-12, pinned by the textbook 04252614/042100005264 pair. JAN and UPC-A are documented onto Ean and Gtin rather than duplicated as prefix-only classes.
MaxWords/MinWords share one unicode word counter and split with PREG_SPLIT_NO_EMPTY — the legacy rule counted the empty fragments around surrounding whitespace, so ' two words ' was four. HtmlClean gains mustContainHtml: for the rich-text-editor-never-loaded case, mirroring its own encoded-markup and bare-< subtleties. Salutation validates against a bundled curated honorific list (case- and trailing-dot-insensitive), replaceable per rule instance, which is the localisation hook the legacy pheg() helper never had.
BsbNumber is format-only on purpose — BSBs carry no check digit and the live-prefix register churns — but what is checkable is checked exactly, D-anchored against the trailing-newline class. HashDigest turns the legacy try/catch-any rule into a 28-algorithm width table: an unknown algorithm now throws at construction naming the known set, instead of silently failing every value at request time. Both advertise their own pattern through ClientCheckable, so the browser checks the same regex the server runs.
Base64 keeps the legacy round-trip core — the one thing it got right —
because strict-decode-then-re-encode catches what a charset regex
cannot: missing padding, embedded whitespace, non-zero discarded bits.
It is deliberately not ClientCheckable for the same reason.
Base64Image validates the DECODED bytes: canonical base64, an optional
human-readable size cap, then finfo-sniffed MIME — never the data-URI
label, which is attacker-written. The legacy rule's image path returned
a closure instead of calling it ('return checkOne() || checkTwo;'), so
it passed everything; its replacement is pinned by a real 1x1 PNG in
both bare and data-URI forms. Support\Encoding\Base64File bridges the
payload to an UploadedFile for Laravel's own file rules — outside
Rules\ because it writes a temp file and the Pure tier forbids that.
DataUri validates RFC 2397 shape and the payload against what the
header claims; media-type restriction requires a declared type, since
the implied text/plain default is what omitting the header gets free.
Rfc3339 validates the grammar PHP's own RFC3339 format rejects (Z, lowercase t/z, fractions, the leap second) and then checks the date against the calendar, so 2023-02-29 fails as a date instead of passing as a shape. TimeOfDay folds the 12/24-hour pair into one rule with a mode and requires the meridiem in 12-hour form — a bare 9:05 on a 12-hour clock is ambiguous. UnixTimestamp accepts the canonical integer form only, not everything is_numeric waves through. DateInterval defers to PHP's parser (the definition that matters, since the value feeds it next) with positive: rejecting the zero duration. MinuteIn pins schedule-slot minutes. MaxDateDifference bounds the absolute distance from a fixed instant or an @sibling field, and fails when the reference is missing — a bound that silently stopped binding is the worse outcome. TimezoneAbbreviation reads PHP's own abbreviation table instead of the legacy 28-entry list. MinimumAge measures completed years in a named timezone, because at 20:00 UTC on the 23rd the same person is 17 in London and 18 in Auckland. Owner decision recorded in the audit: these live in-core (not on laranail/chrono), with the package floor raised to ^8.5.
CardNumber identifies the brand by IIN range through the new CardBrandCatalogue contract (bundled default: the fourteen legacy brands), checks length against what THAT brand issues, and verifies Luhn where the brand carries it — UnionPay does not, universally, so that is a brand property rather than an assumption that declines real cards. Two legacy range bugs die in the port: Discover's 622126-622925 block was excluded from UnionPay as two POINTS instead of an interval, and Troy's inverted lookahead matched every 9-card except a Troy. The typed-exception ergonomics survive as distinct message keys — wrong length, failed checksum and unknown range send the cardholder to different corrections. CardCvc narrows to the sibling number's brand via numberField:, and deliberately falls back to 3-or-4 when that sibling is unrecognisable — a CVC error for a problem in the other field sends the user to the wrong box. CardExpiry reads the spellings embossing uses, is valid through the end of the stated month in a named timezone, and rejects the implausibly distant typo.
The legacy family had one pattern table plus nine per-country traits; this is one country-aware rule where the checksum countries are COMPUTED — NL's 11-proef (or the 2020 sole-proprietor mod-97 form), BE's mod-97 complement, DE's ISO 7064 MOD 11,10, IT's Luhn variant, SE's Luhn+01, EL's power-of-two sum, LU's mod-89 and FR's numeric key — because a transposed pair failing arithmetic is the whole point of these digits, and every reject-vector in the test is a valid pattern with one digit off. Letter-scheme countries (ES, GB, FR alpha keys) are format-only and say so. Punctuation is stripped first; BE 0423.456.765 is how invoices write it. NationalIdentifier gains the VN CCCD scheme the parity matrix flagged missing: 12 digits, province 001-096, and — stated, not faked — no checksum exists.
CompareToColumn replaces the legacy five-class cross-row family with one Database-tier rule parameterised by a Comparison enum; the key is a literal or an @sibling field, comparison is numeric when both sides are ('9' < '10', not the lexicographic lie), and a missing row FAILS — the moment the reference is wrong is the moment enforcement matters. FileExistsOnDisk treats its directory scope as a security boundary: '..' segments, absolute paths, backslashes and null bytes are refused rather than normalised, because '../top-level.txt' existing is exactly the traversal the legacy concatenation allowed. It opens the Storage tier, with its own arch guard. ImageUrl and HasGravatar implement the owner's decision to revive the two probes the plan recommended dropping. ImageUrl HEAD-probes for an image content type with the guards the legacy never had — scheme and loopback/reserved-IP screening before any request, no redirects, bounded timeout — and fails when unreachable, because the probe IS the rule. HasGravatar moves to https and the sha256 endpoint and passes on outage (the DeliverableEmail posture), with the privacy cost stated. Also the two-hour lesson: both probes initially implemented PrecognitionSkippable without the SkipsPrecognition trait, and the resulting autoload fatal was invisible under display_errors=Off — every arch-test run died silently at exit 1. The tier arch tests are now split per facade so the next such failure names its tier.
ReservedUsernameList completes the §5.2.1 contract set. Its default binding is Username's own calibrated 35-name floor — introducing the contract changed no verdict — while ExtendedReservedUsernameList ships the full ~350-entry curated set (the archive's list with its OCR artifacts repaired and its profanity removed: what counts as offensive is TermList policy, per this package's own documented stance). An application binds the extended list, or its own, to change policy everywhere without touching a rule; reserved: on one instance still wins. docs/architecture.md gains 'Not in this library — and why', the durable record of every remaining deliberate drop and relocation from the parity sweep, so none gets re-litigated one rule at a time. The rule-library routing table now points country/currency/language CODES and the everyday time rules at their new in-core homes. Rector ran locally so the branch bot has nothing to add; its one overreach (a CardBrand[] docblock weaker than the contract's list<>) is corrected here.
Benchmark results
|
There was a problem hiding this comment.
Pull request overview
Phase 2 of the Validation 1.0 plan: expands the rule library toward parity with the legacy packages, introduces multiple dataset/catalogue contracts with bundled defaults, and raises the supported runtime floor to PHP ^8.5.
Changes:
- Raise the PHP requirement to
^8.5and align CI workflows/docs with the new floor. - Add new rule families (Chrono, Payment, Encoding, I18n, Storage/DB, etc.) plus contracts and bundled dataset/catalogue implementations.
- Add/expand tests to gate new behavior, including dataset generation checks and architectural tier constraints.
Reviewed changes
Copilot reviewed 94 out of 94 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| UPGRADING.md | Documents the PHP floor breaking change. |
| tools/data-sources/iso-3166.txt | Adds committed ISO 3166 snapshot used for dataset generation. |
| tools/build-datasets.php | Adds dataset build/check tool for committed registry snapshots. |
| tests/Rules/Text/WordsAndSalutationTest.php | Adds coverage for MaxWords/MinWords/Salutation/HtmlClean inversion. |
| tests/Rules/Text/ReservedUsernameListTest.php | Adds coverage for ReservedUsernameList binding behavior. |
| tests/Rules/Storage/FileExistsOnDiskTest.php | Adds coverage for traversal-safe disk existence rule. |
| tests/Rules/RuleTierTest.php | Tightens architecture tier assertions for HTTP and Storage usage. |
| tests/Rules/Payment/PaymentRulesTest.php | Adds coverage for CardNumber/CardCvc/CardExpiry rules and catalogue binding. |
| tests/Rules/Network/ProbeRulesTest.php | Adds coverage for ImageUrl and HasGravatar probes and their postures. |
| tests/Rules/Identifiers/HashDigestAndBsbTest.php | Adds coverage for HashDigest and BsbNumber + clientRules. |
| tests/Rules/I18n/I18nRulesTest.php | Adds I18n rules coverage and dataset --check gate. |
| tests/Rules/Fiscal/VatNumberTest.php | Adds VAT checksum coverage and VN national identifier structure coverage. |
| tests/Rules/Encoding/EncodingRulesTest.php | Adds Base64/Base64Image/DataUri behavior and Base64File bridge coverage. |
| tests/Rules/Database/CompareToColumnTest.php | Adds coverage for CompareToColumn + Comparison enum behavior. |
| tests/Rules/Codes/NewCodesRulesTest.php | Adds coverage for Asin/Ismn/UpcE. |
| tests/Rules/ClientCheckableTest.php | Registers HashDigest as client-checkable in tests. |
| tests/Rules/Chrono/ChronoRulesTest.php | Adds Chrono family rule coverage. |
| tests/RuleRegistrarTest.php | Updates expected counts for expanded rule registry. |
| tests/RuleAliasesTest.php | Adds alias parameter samples for new rules. |
| tests/ConsoleCommandsTest.php | Updates console output expectation to new rule count. |
| src/ValidationServiceProvider.php | Binds new dataset/catalogue contracts via singletonIf fallbacks. |
| src/Support/Text/ExtendedReservedUsernameList.php | Adds opt-in extended reserved username list implementation. |
| src/Support/Text/DefaultReservedUsernameList.php | Adds default ReservedUsernameList implementation delegating to Username floor. |
| src/Support/RuleAliases.php | Adds aliases for new rules and expands unaliased set for non-flat params. |
| src/Support/Payment/CardBrand.php | Adds data model for brand ranges/lengths/CVC/Luhn. |
| src/Support/Payment/BundledCardBrandCatalogue.php | Adds bundled card brand catalogue and identification logic. |
| src/Support/I18n/CodeFile.php | Adds code-file loader for bundled datasets (case-preserving). |
| src/Support/I18n/BundledLanguageDataset.php | Adds bundled ISO 639-1 dataset implementation. |
| src/Support/I18n/BundledCurrencyDataset.php | Adds bundled ISO 4217 dataset + numeric/symbol lookup. |
| src/Support/I18n/BundledCountryDataset.php | Adds bundled ISO 3166 dataset implementation. |
| src/Support/Encoding/Base64File.php | Adds Base64→UploadedFile bridge for running Laravel file rules. |
| src/Rules/Text/Username.php | Switches reserved list resolution to contract binding fallback. |
| src/Rules/Text/Salutation.php | Adds salutation rule backed by bundled list or custom list. |
| src/Rules/Text/MinWords.php | Adds minimum-word-count rule. |
| src/Rules/Text/MaxWords.php | Adds maximum-word-count rule + shared word counting. |
| src/Rules/Text/HtmlClean.php | Adds mustContainHtml inversion mode and makes class readonly. |
| src/Rules/Storage/FileExistsOnDisk.php | Adds traversal-safe disk existence rule. |
| src/Rules/Payment/CardNumber.php | Adds card-number rule using brand catalogue + Luhn/length checks. |
| src/Rules/Payment/CardExpiry.php | Adds expiry parsing and plausibility checks. |
| src/Rules/Payment/CardCvc.php | Adds CVC validation with optional sibling card-number context. |
| src/Rules/Network/ImageUrl.php | Adds guarded HEAD image probe rule (fail-closed). |
| src/Rules/Network/HasGravatar.php | Adds gravatar probe rule (fail-open) with privacy notes. |
| src/Rules/Identifiers/HashDigest.php | Adds algorithm-specific digest validator + clientRules pattern. |
| src/Rules/I18n/LanguageCode.php | Adds ISO 639-1 rule backed by LanguageDataset. |
| src/Rules/I18n/CurrencyCode.php | Adds ISO 4217 alpha/numeric/symbol rule backed by CurrencyDataset. |
| src/Rules/I18n/CountryCode.php | Adds ISO 3166 alpha-2/alpha-3 rule backed by CountryDataset. |
| src/Rules/Fiscal/VatNumber.php | Adds VAT rule with per-country parsing and checksum logic where applicable. |
| src/Rules/Fiscal/NationalIdentifier.php | Adds VN CCCD structure validation. |
| src/Rules/Encoding/DataUri.php | Adds RFC 2397 data URI validation with optional declared-type restriction. |
| src/Rules/Encoding/Base64Image.php | Adds base64 image validation via decode + MIME sniff + byte cap. |
| src/Rules/Encoding/Base64.php | Adds canonical base64 validator via decode→encode roundtrip. |
| src/Rules/Database/Comparison.php | Adds comparison enum used by CompareToColumn. |
| src/Rules/Database/CompareToColumn.php | Adds cross-row column comparison rule using DB lookup + sibling key support. |
| src/Rules/Codes/UpcE.php | Adds UPC-E expansion-based validation with GTIN-12 checksum. |
| src/Rules/Codes/Ismn.php | Adds ISMN (9790-prefixed GTIN-13) validation. |
| src/Rules/Codes/Ean.php | Updates docs to clarify JAN is EAN-13 by prefix. |
| src/Rules/Codes/Asin.php | Adds ASIN rule supporting B-prefixed and ISBN-10 forms. |
| src/Rules/Chrono/UnixTimestamp.php | Adds strict unix timestamp validator. |
| src/Rules/Chrono/TimezoneAbbreviation.php | Adds timezone abbreviation rule based on PHP tzdb abbreviations. |
| src/Rules/Chrono/TimeOfDay.php | Adds 24h/12h time-of-day rule with configurable separator. |
| src/Rules/Chrono/Rfc3339.php | Adds RFC3339 timestamp validator (grammar + calendar check). |
| src/Rules/Chrono/MinuteIn.php | Adds “minute must be in set” rule for time/datetime strings. |
| src/Rules/Chrono/MinimumAge.php | Adds minimum age rule with timezone handling. |
| src/Rules/Chrono/MaxDateDifference.php | Adds absolute max-difference rule with sibling reference support. |
| src/Rules/Chrono/DateInterval.php | Adds ISO-8601 duration rule with optional non-zero requirement. |
| src/Rules/Banking/BsbNumber.php | Adds BSB rule + clientRules pattern. |
| src/Contracts/ReservedUsernameList.php | Adds ReservedUsernameList contract for app-level policy binding. |
| src/Contracts/Payment/CardBrandCatalogue.php | Adds CardBrandCatalogue contract for custom brand catalogues. |
| src/Contracts/I18n/LanguageDataset.php | Adds LanguageDataset contract for I18n rules. |
| src/Contracts/I18n/CurrencyDataset.php | Adds CurrencyDataset contract for I18n rules. |
| src/Contracts/I18n/CountryDataset.php | Adds CountryDataset contract for I18n rules. |
| src/Commands/RulesCommand.php | Updates alias examples for new rules in console output. |
| resources/lang/en/validation.php | Adds translation strings for newly added rules. |
| resources/data/salutations.txt | Adds bundled salutation dataset. |
| resources/data/reserved-usernames.txt | Adds bundled extended reserved usernames dataset. |
| resources/data/iso-639-1.txt | Adds generated ISO 639-1 dataset. |
| resources/data/iso-4217.txt | Adds generated ISO 4217 dataset. |
| resources/data/iso-3166-alpha3.txt | Adds generated ISO 3166 alpha-3 dataset. |
| resources/data/iso-3166-alpha2.txt | Adds generated ISO 3166 alpha-2 dataset. |
| resources/data/currency-symbols.txt | Adds generated CLDR-derived currency symbols list. |
| README.md | Updates stated PHP target to ^8.5. |
| docs/installation.md | Updates installation requirements to PHP 8.5+. |
| docs/architecture.md | Adds “Not in this library — and why” rationale section. |
| composer.json | Raises PHP requirement to ^8.5. |
| CHANGELOG.md | Updates rule counts and family listing to reflect expansions. |
| .github/workflows/run-tests.yml | Drops PHP 8.4 from the test matrix. |
| .github/workflows/release-benchmark.yml | Updates release benchmark workflow to PHP 8.5. |
| .github/workflows/rector.yml | Updates rector workflow to PHP 8.5. |
| .github/workflows/phpstan.yml | Updates phpstan workflow to PHP 8.5. |
| .github/workflows/laravel-13-dev.yml | Updates nightly dev workflow labels/runtime to PHP 8.5. |
| .github/workflows/benchmark.yml | Updates benchmark workflow to PHP 8.5. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+60
to
+65
| if ($year < $currentYear || ($year === $currentYear && $month < $currentMonth)) { | ||
| return false; | ||
| } | ||
|
|
||
| return $year - $currentYear < $this->maxYearsAhead; | ||
| } |
Comment on lines
+10
to
+15
| * A contract because brand data is exactly the kind that outlives any | ||
| * snapshot: ranges are reassigned, regional brands matter to some | ||
| * applications and not others, and a store-card programme is a brand this | ||
| * package has never heard of. The bundled default carries the fifteen | ||
| * legacy brands (corrected — see {@see CardBrand}); bind your own to add, | ||
| * remove or reorder. |
Comment on lines
+41
to
+46
| /** @return array{string, string} name => content pairs, checkable ones only */ | ||
| function buildCheckable(string $sources): array | ||
| { | ||
| // --- ISO 3166 ----------------------------------------------------------- | ||
| $alpha2 = []; | ||
| $alpha3 = []; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2 of the validation 1.0 plan: the rule-parity build. Eleven commits, each slice red-first and gated (Pest 6,272 / 0 failed, PHPStan level max 0 errors, Pint clean, rector run locally and idempotent).
^8.5with the CI matrix in the same commit (owner decision, taken with the Chrono family)CountryDataset/CurrencyDataset/LanguageDataset/CardBrandCatalogue/ReservedUsernameListcontracts, boundsingletonIfover bundled full registries. ISO data is generated from committed official snapshots (tools/build-datasets.php --check, pinned by a suite test) — both local candidate sources were stale in different ways; retired currency codes now fail by testCountryCode,LanguageCode,CurrencyCode(alpha / numeric / symbol)Asin,Ismn,UpcE— each fixing the legacy (UPC-E's check digit belongs to the expanded UPC-A; the legacy checksummed the compressed digits)MaxWords/MinWords,HtmlClean mustContainHtml:, data-backedSalutationBsbNumber,HashDigest(28-algorithm table; unknown algorithm throws at construction) — both ClientCheckableBase64(round-trip),Base64Image(decoded-bytes sniff + human-readable size cap; the legacy returned a closure instead of calling it),DataUri, plus theBase64FileUploadedFile bridgeRfc3339,TimeOfDay,UnixTimestamp,DateInterval,MinuteIn,MaxDateDifference,TimezoneAbbreviation,MinimumAgeCardNumber/CardCvc/CardExpiryover a 14-brand data catalogue, two legacy range bugs fixed (Discover interval vs points; Troy's inverted lookahead), UnionPayluhn: falseVatNumberwith computed national checksums (NL/BE/DE/IT/SE/EL/LU/FR);NationalIdentifier::VNCompareToColumn+Comparisonenum;FileExistsOnDisk(traversal-safe scope); owner-decision probesImageUrl(fail-closed, guarded) andHasGravatar(fail-open, privacy documented)docs/architecture.md"Not in this library — and why" records every remaining deliberate drop/relocation; rule library 54 → 84 rules across 25 families, every count generated-and-guardedThree §14 owner decisions taken via prompt and recorded in the audit: Chrono in-core with the
^8.5floor, full datasets bundled in-core, and the two probes implemented (redesigned) instead of dropped.tag currencywill report red until v0.1.1 is tagged — pre-existing, by design.Full audit:
.claude/design/validation/audits/validation-audit-2026-08-23-phase-2.md(design folder).