Add catalog validation tool
This commit is contained in:
201
tools/validate-catalog.php
Executable file
201
tools/validate-catalog.php
Executable file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$catalogDir = $root . '/catalog';
|
||||
$allowedStatuses = [
|
||||
'draft',
|
||||
'unverified',
|
||||
'verified',
|
||||
'discontinued',
|
||||
'duplicate',
|
||||
];
|
||||
|
||||
$allowedImageExtensions = ['webp', 'jpg', 'jpeg', 'png'];
|
||||
$errors = [];
|
||||
$checked = 0;
|
||||
|
||||
if (!is_dir($catalogDir)) {
|
||||
fwrite(STDERR, "Catalog directory not found: {$catalogDir}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator(
|
||||
$catalogDir,
|
||||
FilesystemIterator::SKIP_DOTS
|
||||
)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file->isFile() || strtolower($file->getExtension()) !== 'json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$checked++;
|
||||
$path = $file->getPathname();
|
||||
$relativePath = substr($path, strlen($root) + 1);
|
||||
|
||||
$raw = file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
$errors[] = "{$relativePath}: unable to read file";
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$record = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $exception) {
|
||||
$errors[] = "{$relativePath}: invalid JSON: {$exception->getMessage()}";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_array($record)) {
|
||||
$errors[] = "{$relativePath}: root value must be an object";
|
||||
continue;
|
||||
}
|
||||
|
||||
$required = [
|
||||
'catalog_version',
|
||||
'record_uuid',
|
||||
'manufacturer',
|
||||
'product_name',
|
||||
'caliber',
|
||||
'status',
|
||||
'last_updated',
|
||||
];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (!array_key_exists($field, $record)) {
|
||||
$errors[] = "{$relativePath}: missing required field '{$field}'";
|
||||
}
|
||||
}
|
||||
|
||||
if (($record['catalog_version'] ?? null) !== 1) {
|
||||
$errors[] = "{$relativePath}: catalog_version must be 1";
|
||||
}
|
||||
|
||||
if (
|
||||
isset($record['record_uuid']) &&
|
||||
!preg_match(
|
||||
'/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
|
||||
(string) $record['record_uuid']
|
||||
)
|
||||
) {
|
||||
$errors[] = "{$relativePath}: record_uuid is not a valid UUID";
|
||||
}
|
||||
|
||||
foreach (['manufacturer', 'product_name', 'caliber'] as $field) {
|
||||
if (
|
||||
isset($record[$field]) &&
|
||||
(!is_string($record[$field]) || trim($record[$field]) === '')
|
||||
) {
|
||||
$errors[] = "{$relativePath}: {$field} must be a non-empty string";
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isset($record['upc']) &&
|
||||
$record['upc'] !== null &&
|
||||
!preg_match('/^[0-9]{8,14}$/', (string) $record['upc'])
|
||||
) {
|
||||
$errors[] = "{$relativePath}: upc must contain 8 to 14 digits";
|
||||
}
|
||||
|
||||
if (
|
||||
isset($record['status']) &&
|
||||
!in_array($record['status'], $allowedStatuses, true)
|
||||
) {
|
||||
$errors[] = "{$relativePath}: invalid status '{$record['status']}'";
|
||||
}
|
||||
|
||||
if (
|
||||
isset($record['last_updated']) &&
|
||||
!preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $record['last_updated'])
|
||||
) {
|
||||
$errors[] = "{$relativePath}: last_updated must use YYYY-MM-DD";
|
||||
}
|
||||
|
||||
if (
|
||||
isset($record['sources']) &&
|
||||
!is_array($record['sources'])
|
||||
) {
|
||||
$errors[] = "{$relativePath}: sources must be an array";
|
||||
}
|
||||
|
||||
if (
|
||||
($record['status'] ?? null) === 'verified' &&
|
||||
empty($record['sources'])
|
||||
) {
|
||||
$errors[] = "{$relativePath}: verified records must include at least one source";
|
||||
}
|
||||
|
||||
if (
|
||||
isset($record['image']) &&
|
||||
$record['image'] !== null
|
||||
) {
|
||||
if (!is_array($record['image'])) {
|
||||
$errors[] = "{$relativePath}: image must be an object or null";
|
||||
} else {
|
||||
$imagePath = $record['image']['path'] ?? null;
|
||||
|
||||
if (!is_string($imagePath) || $imagePath === '') {
|
||||
$errors[] = "{$relativePath}: image.path must be a non-empty string";
|
||||
} else {
|
||||
if (!str_starts_with($imagePath, 'images/')) {
|
||||
$errors[] = "{$relativePath}: image.path must start with images/";
|
||||
}
|
||||
|
||||
if (str_contains($imagePath, '..')) {
|
||||
$errors[] = "{$relativePath}: image.path may not contain ..";
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($imagePath, PATHINFO_EXTENSION));
|
||||
|
||||
if (!in_array($extension, $allowedImageExtensions, true)) {
|
||||
$errors[] = "{$relativePath}: unsupported image extension '{$extension}'";
|
||||
}
|
||||
|
||||
$fullImagePath = $root . '/' . $imagePath;
|
||||
|
||||
if (!is_file($fullImagePath)) {
|
||||
$errors[] = "{$relativePath}: referenced image does not exist: {$imagePath}";
|
||||
}
|
||||
|
||||
$checksum = $record['image']['sha256'] ?? null;
|
||||
|
||||
if (
|
||||
$checksum !== null &&
|
||||
!preg_match('/^[a-f0-9]{64}$/i', (string) $checksum)
|
||||
) {
|
||||
$errors[] = "{$relativePath}: image.sha256 must be 64 hexadecimal characters";
|
||||
}
|
||||
|
||||
if (
|
||||
$checksum !== null &&
|
||||
is_file($fullImagePath)
|
||||
) {
|
||||
$actualChecksum = hash_file('sha256', $fullImagePath);
|
||||
|
||||
if (!hash_equals(strtolower($checksum), strtolower($actualChecksum))) {
|
||||
$errors[] = "{$relativePath}: image SHA-256 does not match";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
fwrite(STDERR, "Catalog validation failed:\n\n");
|
||||
|
||||
foreach ($errors as $error) {
|
||||
fwrite(STDERR, " - {$error}\n");
|
||||
}
|
||||
|
||||
fwrite(STDERR, "\nChecked {$checked} product file(s).\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Catalog validation passed. Checked {$checked} product file(s).\n";
|
||||
Reference in New Issue
Block a user