Skip to content

Phase 2: rule parity — 21 new rules, six dataset contracts, PHP ^8.5 - #5

Merged
imanimanyara merged 11 commits into
mainfrom
phase-2-rule-parity
Aug 24, 2026
Merged

imanimanyara merged 11 commits into
mainfrom
phase-2-rule-parity

Conversation

@imanimanyara

Copy link
Copy Markdown
Member

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).

Area What landed
Floor PHP ^8.5 with the CI matrix in the same commit (owner decision, taken with the Chrono family)
Datasets (§5.2.1) CountryDataset / CurrencyDataset / LanguageDataset / CardBrandCatalogue / ReservedUsernameList contracts, bound singletonIf over 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 test
I18n CountryCode, LanguageCode, CurrencyCode (alpha / numeric / symbol)
Codes Asin, Ismn, UpcE — each fixing the legacy (UPC-E's check digit belongs to the expanded UPC-A; the legacy checksummed the compressed digits)
Text MaxWords / MinWords, HtmlClean mustContainHtml:, data-backed Salutation
Banking / Identifiers BsbNumber, HashDigest (28-algorithm table; unknown algorithm throws at construction) — both ClientCheckable
Encoding Base64 (round-trip), Base64Image (decoded-bytes sniff + human-readable size cap; the legacy returned a closure instead of calling it), DataUri, plus the Base64File UploadedFile bridge
Chrono 8 rules in-core: Rfc3339, TimeOfDay, UnixTimestamp, DateInterval, MinuteIn, MaxDateDifference, TimezoneAbbreviation, MinimumAge
Payment CardNumber / CardCvc / CardExpiry over a 14-brand data catalogue, two legacy range bugs fixed (Discover interval vs points; Troy's inverted lookahead), UnionPay luhn: false
Fiscal VatNumber with computed national checksums (NL/BE/DE/IT/SE/EL/LU/FR); NationalIdentifier::VN
Database / Storage / probes CompareToColumn + Comparison enum; FileExistsOnDisk (traversal-safe scope); owner-decision probes ImageUrl (fail-closed, guarded) and HasGravatar (fail-open, privacy documented)
Docs 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-guarded

Three §14 owner decisions taken via prompt and recorded in the audit: Chrono in-core with the ^8.5 floor, full datasets bundled in-core, and the two probes implemented (redesigned) instead of dropped.

tag currency will 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).

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.
Copilot AI lite review requested due to automatic review settings August 24, 2026 10:22
@github-actions

Copy link
Copy Markdown

Benchmark results

Scenario Optimizations Native Laravel Optimized Speedup Δ vs base
Product import — 500 items, simple rules Wildcard, fast-check 158.0ms 2.8ms ~56x +4%
Nested order lines — 1000 orders × 5 line items Wildcard, fast-check (nested) 1158.9ms 16.4ms ~71x +4%
Event scheduling — 100 items, field-ref dates Wildcard, partial fast-check 27.2ms 1.2ms ~23x +0%
Article submission — 50 items, custom Rule objects Wildcard only 8.9ms 2.8ms ~3x +0%
Conditional import — 100 items, 47 conditional fields Wildcard, pre-evaluation 3410.1ms 43.8ms ~78x +1%
Login form — 3 fields, no wildcards Fast-check (flat) 0.1ms 0.0ms ~10x —

@imanimanyara
imanimanyara merged commit 0de2c8b into main Aug 24, 2026
17 of 18 checks passed
@imanimanyara
imanimanyara deleted the phase-2-rule-parity branch August 24, 2026 10:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.5 and 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 thread tools/build-datasets.php
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 = [];
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.

2 participants