From db44620f76f947e73d34668d14779dfca2a6e180 Mon Sep 17 00:00:00 2001 From: Snider <631881+Snider@users.noreply.github.com> Date: Mon, 2 Feb 2026 01:54:09 +0000 Subject: [PATCH 1/4] Optimize SEO canonical audit updates to use batch query Replaces N+1 update loop with a single batch update query in `AuditCanonicalUrls` command. Adds a reproduction feature test `SeoAuditCanonicalTest`. --- .../Console/Commands/AuditCanonicalUrls.php | 18 ++- .../Tests/Feature/SeoAuditCanonicalTest.php | 107 ++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/Core/Tests/Feature/SeoAuditCanonicalTest.php diff --git a/src/Core/Seo/Console/Commands/AuditCanonicalUrls.php b/src/Core/Seo/Console/Commands/AuditCanonicalUrls.php index 637b408..c65a8a4 100644 --- a/src/Core/Seo/Console/Commands/AuditCanonicalUrls.php +++ b/src/Core/Seo/Console/Commands/AuditCanonicalUrls.php @@ -11,8 +11,10 @@ namespace Core\Seo\Console\Commands; +use Core\Seo\SeoMetadata; use Core\Seo\Validation\CanonicalUrlValidator; use Illuminate\Console\Command; +use Illuminate\Support\Facades\DB; /** * Audit canonical URLs across the site for conflicts and issues. @@ -134,17 +136,25 @@ protected function attemptFixes(array $audit, CanonicalUrlValidator $validator): $fixed = 0; // Fix protocol issues (HTTP -> HTTPS) - foreach ($audit['protocol_issues'] as $record) { + $protocolIssues = $audit['protocol_issues']; + $idsToUpdate = []; + + foreach ($protocolIssues as $record) { $oldUrl = $record->canonical_url; $newUrl = str_replace('http://', 'https://', $oldUrl); - $record->canonical_url = $newUrl; - $record->save(); - $this->line(" Fixed protocol: {$oldUrl} -> {$newUrl}"); + $idsToUpdate[] = $record->id; $fixed++; } + if (! empty($idsToUpdate)) { + SeoMetadata::whereIn('id', $idsToUpdate)->update([ + 'canonical_url' => DB::raw("REPLACE(canonical_url, 'http://', 'https://')"), + 'updated_at' => now(), + ]); + } + // Note: Duplicates and www inconsistencies require manual review if ($audit['duplicates']->isNotEmpty()) { $this->warn(' Duplicate canonical URLs require manual review.'); diff --git a/src/Core/Tests/Feature/SeoAuditCanonicalTest.php b/src/Core/Tests/Feature/SeoAuditCanonicalTest.php new file mode 100644 index 0000000..5c9da77 --- /dev/null +++ b/src/Core/Tests/Feature/SeoAuditCanonicalTest.php @@ -0,0 +1,107 @@ +id(); + $table->timestamps(); + }); + + Schema::create('seo_metadata', function (Blueprint $table) { + $table->id(); + $table->string('seoable_type'); + $table->unsignedBigInteger('seoable_id'); + $table->string('canonical_url')->nullable(); + $table->string('title')->nullable(); + $table->text('description')->nullable(); + $table->json('og_data')->nullable(); + $table->json('twitter_data')->nullable(); + $table->json('schema_markup')->nullable(); + $table->string('robots')->nullable(); + $table->string('focus_keyword')->nullable(); + $table->integer('seo_score')->nullable(); + $table->json('seo_issues')->nullable(); + $table->json('seo_suggestions')->nullable(); + $table->timestamps(); + }); + } + + public function test_fixes_protocol_issues(): void + { + // Create seoables + $item1 = TestSeoable::create(); + $item2 = TestSeoable::create(); + $item3 = TestSeoable::create(); + $item4 = TestSeoable::create(); + + // Create records with HTTP canonical URLs + SeoMetadata::create([ + 'seoable_type' => TestSeoable::class, + 'seoable_id' => $item1->id, + 'canonical_url' => 'http://example.com/post-1', + ]); + + SeoMetadata::create([ + 'seoable_type' => TestSeoable::class, + 'seoable_id' => $item2->id, + 'canonical_url' => 'http://example.com/post-2', + ]); + + // Create a record with HTTPS (should not be touched) + SeoMetadata::create([ + 'seoable_type' => TestSeoable::class, + 'seoable_id' => $item3->id, + 'canonical_url' => 'https://example.com/post-3', + ]); + + // Create a record without canonical URL + SeoMetadata::create([ + 'seoable_type' => TestSeoable::class, + 'seoable_id' => $item4->id, + 'canonical_url' => null, + ]); + + // Run the command with --fix + $this->artisan('seo:audit-canonical', ['--fix' => true]) + ->expectsOutput('Fixed 2 issue(s).') + ->assertExitCode(1); + + // Verify updates + $this->assertEquals('https://example.com/post-1', SeoMetadata::find(1)->canonical_url); + $this->assertEquals('https://example.com/post-2', SeoMetadata::find(2)->canonical_url); + $this->assertEquals('https://example.com/post-3', SeoMetadata::find(3)->canonical_url); + $this->assertNull(SeoMetadata::find(4)->canonical_url); + } +} From 28be75eef46ec625176617941f9a6c612229352c Mon Sep 17 00:00:00 2001 From: Snider <631881+Snider@users.noreply.github.com> Date: Mon, 2 Feb 2026 01:58:14 +0000 Subject: [PATCH 2/4] Fix CI workflow output redirection and upgrade CodeQL action - Remove stderr redirection (`2>&1`) from Psalm, PHPStan, Pint, and Composer Audit commands to prevent progress output from corrupting JSON/SARIF files. - Upgrade `github/codeql-action/upload-sarif` to `v4` to address deprecation warning and invalid SARIF handling. --- .github/workflows/qa.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 0d3ff49..06c07ba 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -130,11 +130,11 @@ jobs: id: stan run: | set +e - vendor/bin/phpstan analyse --error-format=json --no-progress > phpstan.json 2>&1 + vendor/bin/phpstan analyse --error-format=json --no-progress > phpstan.json EXIT_CODE=$? # Also run for GitHub format - vendor/bin/phpstan analyse --error-format=github --no-progress 2>&1 || true + vendor/bin/phpstan analyse --error-format=github --no-progress || true ERRORS=$(jq '.totals.file_errors // 0' phpstan.json 2>/dev/null || echo "0") echo "errors=${ERRORS}" >> $GITHUB_OUTPUT @@ -181,11 +181,11 @@ jobs: id: psalm run: | set +e - vendor/bin/psalm --output-format=json --show-info=false > psalm.json 2>&1 + vendor/bin/psalm --output-format=json --show-info=false > psalm.json EXIT_CODE=$? # Generate SARIF for GitHub Security - vendor/bin/psalm --output-format=sarif --show-info=false > psalm.sarif 2>&1 || true + vendor/bin/psalm --output-format=sarif --show-info=false > psalm.sarif || true ERRORS=$(jq 'length' psalm.json 2>/dev/null || echo "0") echo "errors=${ERRORS}" >> $GITHUB_OUTPUT @@ -200,7 +200,7 @@ jobs: - name: Upload Psalm SARIF if: always() - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: psalm.sarif category: psalm @@ -242,7 +242,7 @@ jobs: id: fmt run: | set +e - OUTPUT=$(vendor/bin/pint --test --format=json 2>&1) + OUTPUT=$(vendor/bin/pint --test --format=json) EXIT_CODE=$? echo "$OUTPUT" > pint.json @@ -292,7 +292,7 @@ jobs: id: audit run: | set +e - composer audit --format=json > audit.json 2>&1 + composer audit --format=json > audit.json EXIT_CODE=$? VULNS=$(jq '.advisories | to_entries | map(.value | length) | add // 0' audit.json 2>/dev/null || echo "0") From ab4daa45bf3051ca390bf3b7a75f295cfd479f10 Mon Sep 17 00:00:00 2001 From: Snider <631881+Snider@users.noreply.github.com> Date: Mon, 2 Feb 2026 02:09:02 +0000 Subject: [PATCH 3/4] Fix Psalm SARIF output validation errors Adds a post-processing step in the QA pipeline to sanitize `psalm.sarif` by ensuring all location coordinates (startLine, startColumn, etc.) are at least 1, as required by the SARIF schema. This resolves the `upload-sarif` action failure. --- .github/workflows/qa.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 06c07ba..6aa140c 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -187,6 +187,22 @@ jobs: # Generate SARIF for GitHub Security vendor/bin/psalm --output-format=sarif --show-info=false > psalm.sarif || true + # Sanitize SARIF file (remove invalid locations where line/column < 1) + if [ -f psalm.sarif ]; then + jq ' + .runs[].results[] |= ( + if .locations then + .locations[] |= ( + if .physicalLocation.region.startLine < 1 then .physicalLocation.region.startLine = 1 else . end | + if .physicalLocation.region.startColumn < 1 then .physicalLocation.region.startColumn = 1 else . end | + if .physicalLocation.region.endLine < 1 then .physicalLocation.region.endLine = 1 else . end | + if .physicalLocation.region.endColumn < 1 then .physicalLocation.region.endColumn = 1 else . end + ) + else . end + ) + ' psalm.sarif > psalm.sarif.tmp && mv psalm.sarif.tmp psalm.sarif + fi + ERRORS=$(jq 'length' psalm.json 2>/dev/null || echo "0") echo "errors=${ERRORS}" >> $GITHUB_OUTPUT From 018fb70ba8a253f54a9fd8265106008a80953da3 Mon Sep 17 00:00:00 2001 From: Snider <631881+Snider@users.noreply.github.com> Date: Mon, 2 Feb 2026 02:12:59 +0000 Subject: [PATCH 4/4] Fix Psalm SARIF output validation with robust jq walk Uses `jq`'s `walk` function to recursively sanitize all `physicalLocation` objects in the SARIF file, ensuring startLine/startColumn/etc are always at least 1. This fixes validation errors from the `upload-sarif` action. --- .github/workflows/qa.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 6aa140c..d5d5a33 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -190,15 +190,15 @@ jobs: # Sanitize SARIF file (remove invalid locations where line/column < 1) if [ -f psalm.sarif ]; then jq ' - .runs[].results[] |= ( - if .locations then - .locations[] |= ( - if .physicalLocation.region.startLine < 1 then .physicalLocation.region.startLine = 1 else . end | - if .physicalLocation.region.startColumn < 1 then .physicalLocation.region.startColumn = 1 else . end | - if .physicalLocation.region.endLine < 1 then .physicalLocation.region.endLine = 1 else . end | - if .physicalLocation.region.endColumn < 1 then .physicalLocation.region.endColumn = 1 else . end - ) - else . end + walk( + if type == "object" and has("physicalLocation") then + .physicalLocation.region.startLine |= (if . < 1 then 1 else . end) | + .physicalLocation.region.startColumn |= (if . < 1 then 1 else . end) | + .physicalLocation.region.endLine |= (if . < 1 then 1 else . end) | + .physicalLocation.region.endColumn |= (if . < 1 then 1 else . end) + else + . + end ) ' psalm.sarif > psalm.sarif.tmp && mv psalm.sarif.tmp psalm.sarif fi