projectRootPath() . '/db/updates'; if (!is_dir($dir)) { return []; } $files = []; foreach (new DirectoryIterator($dir) as $entry) { if ($entry->isDot() || !$entry->isFile()) { continue; } $files[] = $entry->getFilename(); } sort($files); return $files; } public function testUpdateScriptsUseSqlExtension(): void { $nonSql = []; foreach ($this->updateScripts() as $file) { if (!str_ends_with($file, '.sql')) { $nonSql[] = $file; } } $this->assertSame([], $nonSql, "Non-SQL files found in db/updates/:\n" . implode("\n", $nonSql)); } public function testUpdateScriptsFollowNamingConvention(): void { $invalid = []; foreach ($this->updateScripts() as $file) { if (!preg_match('/^\d{4}-\d{2}-\d{2}-.+\.sql$/', $file)) { $invalid[] = $file; } } $this->assertSame( [], $invalid, "db/updates/ scripts must follow YYYY-MM-DD-description.sql naming:\n" . implode("\n", $invalid) ); } public function testUpdateScriptsAreNotEmpty(): void { $empty = []; foreach ($this->updateScripts() as $file) { $content = trim($this->readProjectFile('db/updates/' . $file)); if ($content === '') { $empty[] = $file; } } $this->assertSame([], $empty, "Empty scripts found in db/updates/:\n" . implode("\n", $empty)); } public function testUpdateScriptsUseIdempotentPatterns(): void { $violations = []; foreach ($this->updateScripts() as $file) { $content = $this->readProjectFile('db/updates/' . $file); $upper = strtoupper($content); // Every script must contain at least one idempotent pattern. $hasIfNotExists = str_contains($upper, 'IF NOT EXISTS'); $hasOnDuplicateKey = str_contains($upper, 'ON DUPLICATE KEY'); $hasInsertIgnore = str_contains($upper, 'INSERT IGNORE'); $hasIfExists = str_contains($upper, 'IF EXISTS'); $hasDropIfExists = (bool) preg_match('/DROP\s+(TABLE|INDEX|COLUMN)\s+IF\s+EXISTS/i', $content); // UPDATE ... SET is inherently idempotent (convergent writes). $hasIdempotentUpdate = (bool) preg_match('/UPDATE\s+\S+\s+SET\s+/i', $content); if (!$hasIfNotExists && !$hasOnDuplicateKey && !$hasInsertIgnore && !$hasIfExists && !$hasDropIfExists && !$hasIdempotentUpdate) { $violations[] = $file . ' (no idempotent guard found)'; } // Unguarded CREATE TABLE is forbidden. if (preg_match('/CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i', $content)) { $violations[] = $file . ' (CREATE TABLE without IF NOT EXISTS)'; } } $this->assertSame( [], $violations, "db/updates/ scripts must use idempotent SQL patterns (GR-CORE-010):\n" . implode("\n", $violations) ); } }