diff --git a/AGENTS.md b/AGENTS.md index db5baa9..cb06298 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,8 @@ castor spec:check:samsung # Samsung model keyset castor spec:baseline:google # Refresh Google baseline castor spec:baseline:apple # Refresh Apple keyset castor spec:baseline:samsung # Refresh Samsung keyset +castor spec:diff:google # Diff live discovery enums against PHP models +castor spec:diff:google --properties # Include schema property comparison ``` CI runs 4 jobs: cs-check, spec-check, phpstan, and tests (PHP 8.3/8.4/8.5 matrix). diff --git a/castor.php b/castor.php index 9540036..dcfea77 100644 --- a/castor.php +++ b/castor.php @@ -80,6 +80,16 @@ function spec_baseline_google(): void run([spec_tools_php(), __DIR__ . '/tools/spec/google-wallet-spec.php', 'baseline']); } +#[AsTask('diff:google', namespace: 'spec', description: 'Diff live Google Wallet discovery enums/properties against PHP models')] +function spec_diff_google(bool $properties = false): void +{ + $args = [spec_tools_php(), __DIR__ . '/tools/spec/google-wallet-diff.php']; + if ($properties) { + $args[] = '--properties'; + } + run($args); +} + #[AsTask('check:samsung', namespace: 'spec', description: 'Compare Samsung Wallet phpstan keyset to tools/spec/samsung-wallet-keyset.json')] function spec_check_samsung(): void { diff --git a/src/Pass/Android/Model/Flight/FlightClass.php b/src/Pass/Android/Model/Flight/FlightClass.php index 44fbaf5..7141b84 100644 --- a/src/Pass/Android/Model/Flight/FlightClass.php +++ b/src/Pass/Android/Model/Flight/FlightClass.php @@ -39,12 +39,13 @@ * @phpstan-import-type AppLinkDataType from AppLinkData * @phpstan-import-type BoardingPolicy from BoardingPolicyEnum * @phpstan-import-type SeatClassPolicy from SeatClassPolicyEnum + * @phpstan-import-type FlightStatus from FlightStatusEnum * @phpstan-import-type MerchantLocationType from MerchantLocation * @phpstan-import-type ValueAddedModuleDataType from ValueAddedModuleData * @phpstan-import-type NotifyPreference from NotifyPreferenceEnum * @phpstan-import-type ReviewType from Review * - * @phpstan-type FlightClassType array{id: string, issuerName: string, reviewStatus: ReviewStatus, origin: AirportInfoType, destination: AirportInfoType, flightHeader: FlightHeaderType, localScheduledDepartureDateTime?: string, localEstimatedOrActualDepartureDateTime?: string, localBoardingDateTime?: string, localScheduledArrivalDateTime?: string, localEstimatedOrActualArrivalDateTime?: string, localGateClosingDateTime?: string, boardingPolicy?: BoardingPolicy, seatClassPolicy?: SeatClassPolicy, localizedIssuerName?: LocalizedStringType, hexBackgroundColor?: string, countryCode?: string, heroImage?: ImageType, enableSmartTap?: bool, redemptionIssuers?: list, multipleDevicesAndHoldersAllowedStatus?: MultipleDevicesAndHoldersAllowedStatus, callbackOptions?: CallbackOptionsType, securityAnimation?: SecurityAnimationType, viewUnlockRequirement?: ViewUnlockRequirement, messages?: list, imageModulesData?: list, textModulesData?: list, linksModuleData?: LinksModuleDataType, appLinkData?: AppLinkDataType, languageOverride?: string, merchantLocations?: list, valueAddedModuleData?: list, notifyPreference?: NotifyPreference, review?: ReviewType} + * @phpstan-type FlightClassType array{id: string, issuerName: string, reviewStatus: ReviewStatus, origin: AirportInfoType, destination: AirportInfoType, flightHeader: FlightHeaderType, localScheduledDepartureDateTime?: string, localEstimatedOrActualDepartureDateTime?: string, localBoardingDateTime?: string, localScheduledArrivalDateTime?: string, localEstimatedOrActualArrivalDateTime?: string, localGateClosingDateTime?: string, flightStatus?: FlightStatus, boardingPolicy?: BoardingPolicy, seatClassPolicy?: SeatClassPolicy, localizedIssuerName?: LocalizedStringType, hexBackgroundColor?: string, countryCode?: string, heroImage?: ImageType, enableSmartTap?: bool, redemptionIssuers?: list, multipleDevicesAndHoldersAllowedStatus?: MultipleDevicesAndHoldersAllowedStatus, callbackOptions?: CallbackOptionsType, securityAnimation?: SecurityAnimationType, viewUnlockRequirement?: ViewUnlockRequirement, messages?: list, imageModulesData?: list, textModulesData?: list, linksModuleData?: LinksModuleDataType, appLinkData?: AppLinkDataType, languageOverride?: string, merchantLocations?: list, valueAddedModuleData?: list, notifyPreference?: NotifyPreference, review?: ReviewType} */ class FlightClass { @@ -69,6 +70,7 @@ public function __construct( public ?string $localScheduledArrivalDateTime = null, public ?string $localEstimatedOrActualArrivalDateTime = null, public ?string $localGateClosingDateTime = null, + public ?FlightStatusEnum $flightStatus = null, public ?BoardingPolicyEnum $boardingPolicy = null, public ?SeatClassPolicyEnum $seatClassPolicy = null, public ?LocalizedString $localizedIssuerName = null, diff --git a/src/Pass/Android/Model/Flight/FlightStatusEnum.php b/src/Pass/Android/Model/Flight/FlightStatusEnum.php new file mode 100644 index 0000000..3bd111c --- /dev/null +++ b/src/Pass/Android/Model/Flight/FlightStatusEnum.php @@ -0,0 +1,19 @@ +localGateClosingDateTime; } + if (null !== $object->flightStatus) { + $data['flightStatus'] = $object->flightStatus->value; + } + if (null !== $object->boardingPolicy) { $data['boardingPolicy'] = $object->boardingPolicy->value; } diff --git a/tests/Pass/Android/Normalizer/FlightNormalizerTest.php b/tests/Pass/Android/Normalizer/FlightNormalizerTest.php index 16fca76..ae715c4 100644 --- a/tests/Pass/Android/Normalizer/FlightNormalizerTest.php +++ b/tests/Pass/Android/Normalizer/FlightNormalizerTest.php @@ -13,6 +13,7 @@ use Jolicode\WalletKit\Pass\Android\Model\Flight\FlightClass; use Jolicode\WalletKit\Pass\Android\Model\Flight\FlightHeader; use Jolicode\WalletKit\Pass\Android\Model\Flight\FlightObject; +use Jolicode\WalletKit\Pass\Android\Model\Flight\FlightStatusEnum; use Jolicode\WalletKit\Pass\Android\Model\Flight\FrequentFlyerInfo; use Jolicode\WalletKit\Pass\Android\Model\Flight\ReservationInfo; use Jolicode\WalletKit\Pass\Android\Model\Flight\SeatClassPolicyEnum; @@ -189,6 +190,7 @@ public function testFlightPass(): void localScheduledDepartureDateTime: '2025-08-15T10:00', localScheduledArrivalDateTime: '2025-08-15T14:30', localBoardingDateTime: '2025-08-15T09:30', + flightStatus: FlightStatusEnum::SCHEDULED, boardingPolicy: BoardingPolicyEnum::ZONE_BASED, seatClassPolicy: SeatClassPolicyEnum::CABIN_BASED, hexBackgroundColor: Color::fromHex('#003366'), @@ -235,6 +237,7 @@ classId: 'flight-class-1', self::assertSame('JFK', $classData['destination']['airportIataCode']); self::assertSame('AF', $classData['flightHeader']['carrier']['carrierIataCode']); self::assertSame('123', $classData['flightHeader']['flightNumber']); + self::assertSame('SCHEDULED', $classData['flightStatus']); self::assertSame('ZONE_BASED', $classData['boardingPolicy']); self::assertSame('flight-object-1', $objectData['id']); diff --git a/tools/spec/google-wallet-baseline.json b/tools/spec/google-wallet-baseline.json index 7059e56..7df255f 100644 --- a/tools/spec/google-wallet-baseline.json +++ b/tools/spec/google-wallet-baseline.json @@ -1,5 +1,5 @@ { - "revision": "20260409", + "revision": "20260410", "version": "v1", - "updatedAt": "2026-04-09T20:19:32+00:00" + "updatedAt": "2026-04-10T22:43:31+00:00" } diff --git a/tools/spec/google-wallet-diff.php b/tools/spec/google-wallet-diff.php new file mode 100644 index 0000000..b19cf06 --- /dev/null +++ b/tools/spec/google-wallet-diff.php @@ -0,0 +1,398 @@ +#!/usr/bin/env php +> $discoveryEnums schema.property => values */ +$discoveryEnums = []; +$schemas = $discovery['schemas'] ?? []; +foreach ($schemas as $schemaName => $schema) { + if (!isset($schema['properties']) || !\is_array($schema['properties'])) { + continue; + } + foreach ($schema['properties'] as $propName => $prop) { + if (isset($prop['enum']) && \is_array($prop['enum'])) { + $discoveryEnums["{$schemaName}.{$propName}"] = $prop['enum']; + } + } +} + +// ── 3. Scan PHP enums ──────────────────────────────────────────────────────── + +/** @var array}> $phpEnums */ +$phpEnums = scanPhpEnums($modelDir); + +// ── 4. Match discovery enums to PHP enums ──────────────────────────────────── +// Strategy: for each discovery enum, find the PHP enum whose UPPER_CASE +// values have the best overlap. + +/** @var array $matchedDiscoveryToPhp */ +$matchedDiscoveryToPhp = []; + +/** @var array> $matchedPhpToDiscovery */ +$matchedPhpToDiscovery = []; + +foreach ($discoveryEnums as $discoveryPath => $discoveryValues) { + $upperValues = array_filter($discoveryValues, fn (string $v): bool => isUpperCase($v)); + + $bestMatch = null; + $bestOverlap = 0; + foreach ($phpEnums as $phpClass => $phpData) { + $overlap = \count(array_intersect($phpData['values'], $upperValues)); + if ($overlap > $bestOverlap) { + $bestOverlap = $overlap; + $bestMatch = $phpClass; + } + } + + $minOverlap = min(2, \count($phpEnums[$bestMatch]['values'] ?? [])); + if ($bestMatch !== null && $bestOverlap >= $minOverlap) { + $matchedDiscoveryToPhp[$discoveryPath] = $bestMatch; + $matchedPhpToDiscovery[$bestMatch][] = $discoveryPath; + } +} + +// ── 5. Report enum differences ─────────────────────────────────────────────── + +$hasActionable = false; +$actionableEnums = []; +$infoEnums = []; + +foreach ($matchedPhpToDiscovery as $phpClass => $discoveryPaths) { + $phpValues = $phpEnums[$phpClass]['values']; + + $allDiscoveryValues = []; + foreach ($discoveryPaths as $dp) { + $allDiscoveryValues = array_merge($allDiscoveryValues, $discoveryEnums[$dp]); + } + $allDiscoveryValues = array_values(array_unique($allDiscoveryValues)); + + $upperDiscovery = array_values(array_unique(array_filter($allDiscoveryValues, fn (string $v): bool => isUpperCase($v)))); + $camelDiscovery = array_values(array_filter($allDiscoveryValues, fn (string $v): bool => !isUpperCase($v))); + + $missingUpper = array_values(array_diff($upperDiscovery, $phpValues)); + $extraInPhp = array_values(array_diff($phpValues, $upperDiscovery)); + + sort($missingUpper); + sort($camelDiscovery); + sort($extraInPhp); + + if ($missingUpper === [] && $extraInPhp === []) { + continue; + } + + $relFile = str_replace($root . '/', '', $phpEnums[$phpClass]['file']); + + if ($missingUpper !== []) { + $hasActionable = true; + $actionableEnums[] = [ + 'class' => $phpClass, + 'file' => $relFile, + 'discovery' => $discoveryPaths, + 'missingUpper' => $missingUpper, + 'camel' => $camelDiscovery, + 'extra' => $extraInPhp, + ]; + } elseif ($extraInPhp !== []) { + $infoEnums[] = [ + 'class' => $phpClass, + 'file' => $relFile, + 'discovery' => $discoveryPaths, + 'extra' => $extraInPhp, + ]; + } +} + +// Print actionable enums first +if ($actionableEnums !== []) { + echo "ENUMS WITH MISSING VALUES\n\n"; + foreach ($actionableEnums as $e) { + echo " {$e['class']} ({$e['file']})\n"; + echo " Discovery: " . implode(', ', $e['discovery']) . "\n"; + echo " Missing: " . implode(', ', $e['missingUpper']) . "\n"; + if ($e['extra'] !== []) { + echo " Extra in PHP: " . implode(', ', $e['extra']) . "\n"; + } + echo "\n"; + } +} + +// Print enums with extra values in PHP +if ($infoEnums !== []) { + echo "ENUMS WITH EXTRA PHP VALUES (not in discovery)\n\n"; + foreach ($infoEnums as $e) { + echo " {$e['class']} ({$e['file']})\n"; + echo " Extra in PHP: " . implode(', ', $e['extra']) . "\n"; + echo "\n"; + } +} + +if ($actionableEnums === [] && $infoEnums === []) { + echo "All matched enums are up to date.\n\n"; +} + +// Unmatched discovery enums +$unmatchedDiscovery = array_diff_key($discoveryEnums, $matchedDiscoveryToPhp); +if ($unmatchedDiscovery !== []) { + echo str_repeat('─', 72) . "\n"; + echo "UNMATCHED DISCOVERY ENUMS (no PHP enum found)\n\n"; + + foreach ($unmatchedDiscovery as $discoveryPath => $values) { + $upperValues = array_values(array_filter($values, fn (string $v): bool => isUpperCase($v))); + sort($upperValues); + echo " {$discoveryPath}\n"; + echo " Values: " . implode(', ', $upperValues) . "\n"; + } + + echo "\n"; +} + +// ── 6. Schema property comparison (opt-in) ─────────────────────────────────── + +if ($showProperties) { + echo str_repeat('─', 72) . "\n"; + echo "SCHEMA PROPERTY COMPARISON\n\n"; + + $phpModels = scanPhpModelProperties($modelDir); + $hasPropertyDiff = false; + + foreach ($phpModels as $phpClass => $phpData) { + $schemaName = $phpData['schemaName']; + if (!isset($schemas[$schemaName]['properties'])) { + continue; + } + + $discoveryProps = array_keys($schemas[$schemaName]['properties']); + $phpProps = $phpData['properties']; + + $missingInPhp = array_values(array_diff($discoveryProps, $phpProps)); + $extraInPhp = array_values(array_diff($phpProps, $discoveryProps)); + + sort($missingInPhp); + sort($extraInPhp); + + if ($missingInPhp === [] && $extraInPhp === []) { + continue; + } + + $hasPropertyDiff = true; + $relFile = str_replace($root . '/', '', $phpData['file']); + echo " {$phpClass} <> {$schemaName} ({$relFile})\n"; + + if ($missingInPhp !== []) { + echo " Not in PHP: " . implode(', ', $missingInPhp) . "\n"; + } + if ($extraInPhp !== []) { + echo " Extra in PHP: " . implode(', ', $extraInPhp) . "\n"; + } + echo "\n"; + } + + if (!$hasPropertyDiff) { + echo " No property differences on modeled schemas.\n\n"; + } +} + +// ── Summary ────────────────────────────────────────────────────────────────── + +echo str_repeat('─', 72) . "\n"; + +if ($hasActionable) { + echo "Result: actionable differences found.\n"; + exit(1); +} + +echo "Result: no actionable differences (models are up to date).\n"; +exit(0); + +// ═════════════════════════════════════════════════════════════════════════════ +// Helper functions +// ═════════════════════════════════════════════════════════════════════════════ + +function isUpperCase(string $value): bool +{ + return $value === strtoupper($value); +} + +/** + * @return array}> + */ +function scanPhpEnums(string $modelDir): array +{ + $enums = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($modelDir)); + + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || !str_ends_with($file->getFilename(), 'Enum.php')) { + continue; + } + + $content = file_get_contents($file->getPathname()); + if ($content === false) { + continue; + } + + if (!preg_match('/enum\s+(\w+Enum)\s*:\s*string/', $content, $m)) { + continue; + } + + preg_match_all("/case\s+\w+\s*=\s*'([^']+)'/", $content, $caseMatches); + $values = $caseMatches[1]; + sort($values); + + $enums[$m[1]] = [ + 'file' => $file->getPathname(), + 'values' => $values, + ]; + } + + return $enums; +} + +/** + * @return array}> + */ +function scanPhpModelProperties(string $modelDir): array +{ + $models = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($modelDir)); + + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || $file->getExtension() !== 'php') { + continue; + } + + $filename = $file->getFilename(); + if (!preg_match('/^((?:EventTicket|Flight|Generic|GiftCard|Loyalty|Offer|Transit)(?:Class|Object))\.php$/', $filename, $m)) { + continue; + } + + $className = $m[1]; + $content = file_get_contents($file->getPathname()); + if ($content === false) { + continue; + } + + $typeName = $className . 'Type'; + $pattern = '/@phpstan-type\s+' . preg_quote($typeName, '/') . '\s+array\s*\{/'; + if (!preg_match($pattern, $content, $tm, PREG_OFFSET_CAPTURE)) { + continue; + } + + $openPos = $tm[0][1] + strlen($tm[0][0]) - 1; + $body = extractBalancedBraces($content, $openPos); + + $props = []; + if (preg_match_all('/(?:^|,\s*)([a-zA-Z_][a-zA-Z0-9_]*)\??\s*:/m', $body, $pm)) { + $props = $pm[1]; + } + + $props = array_values(array_unique($props)); + sort($props); + + $models[$className] = [ + 'file' => $file->getPathname(), + 'schemaName' => $className, + 'properties' => $props, + ]; + } + + return $models; +} + +function extractBalancedBraces(string $content, int $openPos): string +{ + $len = strlen($content); + if ($openPos >= $len || $content[$openPos] !== '{') { + return ''; + } + + $depth = 0; + $bodyStart = $openPos + 1; + + for ($i = $openPos; $i < $len; $i++) { + if ($content[$i] === '{') { + $depth++; + } elseif ($content[$i] === '}') { + $depth--; + if ($depth === 0) { + return substr($content, $bodyStart, $i - $bodyStart); + } + } + } + + return ''; +} + +function fetchDiscoveryJson(): ?string +{ + $ctx = stream_context_create([ + 'http' => [ + 'timeout' => 30, + 'header' => "Accept: application/json\r\n", + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $result = @file_get_contents(DISCOVERY_URL, false, $ctx); + + return \is_string($result) && $result !== '' ? $result : null; +}