Repository: l2beat/l2beat
Pull request #7364: Add config diff step
Author: login: sz-piotr
Created: 2025-04-02T12:18:44Z | Merged: 2025-04-04T10:14:28Z



Diff:
diff --git a/.github/workflows/config-diff.yml b/.github/workflows/config-diff.yml
new file mode 100644
index 000000000000..7c169c009af3
--- /dev/null
+++ b/.github/workflows/config-diff.yml
@@ -0,0 +1,117 @@
+name: Diff
+
+on:
+  pull_request:
+
+permissions:
+  pages: write # For GitHub Pages deployment
+  id-token: write # For authentication with GitHub Pages
+  contents: write # For GitHub Pages deployment
+  pull-requests: write # For commenting on PRs
+
+jobs:
+  config-diff:
+    runs-on: ubuntu-latest
+    env:
+      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
+      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          ref: ${{ github.event.pull_request.head.sha }}
+          fetch-depth: 0
+
+      - name: Fetch branches
+        run: git fetch origin main:main
+
+      - uses: pnpm/action-setup@v4
+        name: Install pnpm
+        with:
+          run_install: false
+
+      - name: Install Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: 22
+          cache: 'pnpm'
+
+      - name: Compute diff
+        id: diff
+        run: |
+          bash ./packages/config/scripts/diff/diff.sh
+          echo "has_changes=$(cat /tmp/compare/diff.html | grep -c "No changes detected" || true)" >> $GITHUB_OUTPUT
+
+      - name: Setup GitHub Pages artifact
+        if: steps.diff.outputs.has_changes != '1'
+        run: |
+          PR_NUMBER="${{ github.event.pull_request.number }}"
+          REPORT_DIR="pages-output/pr-$PR_NUMBER"
+          mkdir -p "$REPORT_DIR"
+          cp /tmp/compare/diff.html "$REPORT_DIR/index.html"
+
+      - name: Save gh-pages files
+        if: steps.diff.outputs.has_changes != '1'
+        uses: JamesIves/github-pages-deploy-action@v4
+        with:
+          branch: gh-pages
+          folder: pages-output
+          single-commit: true
+          clean: false
+
+      - name: Add gh-pages to worktree
+        if: steps.diff.outputs.has_changes != '1'
+        run: git worktree add gh-pages gh-pages
+
+      - name: Upload GitHub Pages artifact
+        if: steps.diff.outputs.has_changes != '1'
+        uses: actions/upload-pages-artifact@v3
+        with:
+          path: gh-pages
+
+      - name: Deploy to GitHub Pages
+        id: deployment
+        if: steps.diff.outputs.has_changes != '1'
+        uses: actions/deploy-pages@v4
+
+      - name: Find Comment
+        uses: peter-evans/find-comment@v2
+        id: find-comment
+        with:
+          issue-number: ${{ github.event.pull_request.number }}
+          comment-author: 'github-actions[bot]'
+          body-includes: 'Config Changes Report'
+
+      - name: Get current timestamp
+        id: timestamp
+        run: echo "value=$(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT
+
+      - name: Create or Update Comment (Changes Detected)
+        if: steps.diff.outputs.has_changes != '1'
+        uses: peter-evans/create-or-update-comment@v2
+        with:
+          comment-id: ${{ steps.find-comment.outputs.comment-id }}
+          issue-number: ${{ github.event.pull_request.number }}
+          body: |
+            ## Config Changes Report
+            
+            Config changes have been detected between main and this PR.
+            
+            [View Full Report](https://l2beat.github.io/l2beat/pr-${{ github.event.pull_request.number }})
+            
+            *Last updated: ${{ steps.timestamp.outputs.value }}*
+          edit-mode: replace
+          
+      - name: Create or Update Comment (No Changes)
+        if: steps.diff.outputs.has_changes == '1'
+        uses: peter-evans/create-or-update-comment@v2
+        with:
+          comment-id: ${{ steps.find-comment.outputs.comment-id }}
+          issue-number: ${{ github.event.pull_request.number }}
+          body: |
+            ## Config Changes Report
+            
+            No schema changes detected between main and this PR.
+            
+            *Last updated: ${{ steps.timestamp.outputs.value }}*
+          edit-mode: replace
\ No newline at end of file
diff --git a/packages/config/package.json b/packages/config/package.json
index 73b1663e56c4..e2103c8101e7 100644
--- a/packages/config/package.json
+++ b/packages/config/package.json
@@ -22,7 +22,8 @@
     "tokens": "node -r esbuild-register scripts/tokens/tokens.ts",
     "tokens:verify": "sh scripts/tokens/verify.sh",
     "coingecko:platforms": "node -r esbuild-register scripts/getCoingeckoPlatforms.ts",
-    "stargatev2-pools": "node -r esbuild-register scripts/stargatev2-pools"
+    "stargatev2-pools": "node -r esbuild-register scripts/stargatev2-pools",
+    "diff": "./scripts/diff/diff.sh"
   },
   "dependencies": {
     "@l2beat/discovery": "workspace:*",
@@ -36,10 +37,13 @@
     "@l2beat/backend-tools": "workspace:*",
     "@l2beat/shared": "workspace:*",
     "@l2beat/typescript-config": "workspace:*",
+    "@types/diff": "^7.0.2",
     "@types/lodash": "^4.17.12",
     "@types/node-fetch": "^2.6.11",
+    "alpinejs": "^3.14.9",
     "chalk": "^4.1.2",
     "cheerio": "^1.0.0-rc.11",
+    "diff": "^7.0.0",
     "dotenv": "^16.4.5",
     "jsonc-parser": "^3.2.0",
     "node-fetch": "^2.6.7",
diff --git a/packages/config/scripts/diff/diff.sh b/packages/config/scripts/diff/diff.sh
new file mode 100755
index 000000000000..df9d4008f1e3
--- /dev/null
+++ b/packages/config/scripts/diff/diff.sh
@@ -0,0 +1,30 @@
+#! /bin/bash
+set -xe
+
+cd $(git rev-parse --show-toplevel)
+
+GIT_HEAD=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse HEAD)
+GIT_MAIN=$(git merge-base main $GIT_HEAD)
+
+git checkout $GIT_MAIN
+pnpm install
+pnpm build:dependencies
+
+mkdir -p /tmp/compare/main
+cp packages/config/build/db.sqlite /tmp/compare/main/db.sqlite
+git rev-parse HEAD > /tmp/compare/main/commit
+
+git checkout $GIT_HEAD
+pnpm install
+pnpm build:dependencies
+
+mkdir -p /tmp/compare/pr
+cp packages/config/build/db.sqlite /tmp/compare/pr/db.sqlite
+git rev-parse HEAD > /tmp/compare/pr/commit
+
+cd packages/config
+node -r esbuild-register scripts/diff/index.ts /tmp/compare/diff.html
+
+if [ -z "$GITHUB_ACTIONS" ]; then
+  open /tmp/compare/diff.html
+fi
diff --git a/packages/config/scripts/diff/diff.ts b/packages/config/scripts/diff/diff.ts
new file mode 100644
index 000000000000..0ac111e7a9c9
--- /dev/null
+++ b/packages/config/scripts/diff/diff.ts
@@ -0,0 +1,149 @@
+import { type Change, diffLines } from 'diff'
+import type { Project } from './types'
+
+export interface ProjectDiff {
+  id: string
+  type: 'added' | 'removed' | 'modified'
+  fields: FieldDiff[]
+  added: number
+  removed: number
+}
+
+export interface FieldDiff {
+  field: string
+  diff: LineChange[]
+  added: number
+  removed: number
+}
+
+export interface LineChange {
+  type: 'full' | 'ctx' | 'added' | 'removed'
+  value: string
+}
+
+export function diffAll(
+  projectsBefore: Project[],
+  projectsAfter: Project[],
+): ProjectDiff[] {
+  const beforeMap = new Map(projectsBefore.map((p) => [p.id, p]))
+  const afterMap = new Map(projectsAfter.map((p) => [p.id, p]))
+
+  const results: ProjectDiff[] = []
+
+  for (const [id, before] of beforeMap) {
+    const after = afterMap.get(id)
+    if (!after) {
+      results.push(addedOrRemoved(before, 'removed'))
+    } else {
+      const diff = diffProjects(before, after)
+      if (diff.length > 0) {
+        const added = diff.reduce((sum, a) => sum + a.added, 0)
+        const removed = diff.reduce((sum, a) => sum + a.removed, 0)
+        results.push({
+          id: before.id,
+          type: 'modified',
+          fields: diff,
+          added,
+          removed,
+        })
+      }
+    }
+  }
+
+  for (const [id, after] of afterMap) {
+    const before = beforeMap.get(id)
+    if (!before) {
+      results.push(addedOrRemoved(after, 'added'))
+    }
+  }
+
+  return results.sort((a, b) => a.id.localeCompare(b.id))
+}
+
+function addedOrRemoved(
+  project: Project,
+  type: 'added' | 'removed',
+): ProjectDiff {
+  const fields = Object.entries(project).map(([key, value]) => {
+    const lines = JSON.stringify(toCanonicalJson(value), null, 2).split('\n')
+    return {
+      field: key,
+      diff: lines.map((value) => ({ type, value })),
+      added: type === 'added' ? lines.length : 0,
+      removed: type === 'removed' ? lines.length : 0,
+    }
+  })
+  const added = fields.reduce((sum, a) => sum + a.added, 0)
+  const removed = fields.reduce((sum, a) => sum + a.removed, 0)
+  return { id: project.id, type, fields, added, removed }
+}
+
+function diffProjects(before: Project, after: Project): FieldDiff[] {
+  const result: FieldDiff[] = []
+  for (const field in before) {
+    const output = diffLines(
+      JSON.stringify(toCanonicalJson(before[field]), null, 2),
+      JSON.stringify(toCanonicalJson(after[field]), null, 2),
+    )
+    if (output.some((x) => x.added || x.removed)) {
+      const diff = processDiffOutput(output, 5)
+      const added = diff.reduce(
+        (count, a) => count + (a.type === 'added' ? 1 : 0),
+        0,
+      )
+      const removed = diff.reduce(
+        (count, a) => count + (a.type === 'removed' ? 1 : 0),
+        0,
+      )
+      result.push({ field, diff, added, removed })
+    }
+  }
+  return result
+}
+
+function processDiffOutput(
+  changes: Change[],
+  contextSize: number,
+): LineChange[] {
+  const result: LineChange[] = []
+  for (const [_i, change] of changes.entries()) {
+    const lines = change.value.split('\n')
+    if (change.value.endsWith('\n')) {
+      lines.pop()
+    }
+    if (change.added) {
+      result.push(
+        ...lines.map((value): LineChange => ({ type: 'added', value })),
+      )
+    } else if (change.removed) {
+      result.push(
+        ...lines.map((value): LineChange => ({ type: 'removed', value })),
+      )
+    } else {
+      result.push(
+        ...lines.map(
+          (value, i): LineChange => ({
+            type:
+              i > contextSize && i < lines.length - contextSize
+                ? 'full'
+                : 'ctx',
+            value,
+          }),
+        ),
+      )
+    }
+  }
+  return result
+}
+
+function toCanonicalJson(value: unknown) {
+  if (typeof value !== 'object' || value === null) {
+    return value
+  }
+  if (Array.isArray(value)) {
+    return value
+  }
+  return Object.fromEntries(
+    Object.entries(value).sort(([a], [b]) => a.localeCompare(b)),
+  )
+}
diff --git a/packages/config/scripts/diff/html.ts b/packages/config/scripts/diff/html.ts
new file mode 100644
index 000000000000..8ed7c1c91bf9
--- /dev/null
+++ b/packages/config/scripts/diff/html.ts
@@ -0,0 +1,176 @@
+import { readFileSync } from 'fs'
+import type { FieldDiff, LineChange, ProjectDiff } from './diff'
+
+export function diffsToHtml(props: {
+  diffs: ProjectDiff[]
+  commitBefore: string
+  commitAfter: string
+}) {
+  const hashBefore = props.commitBefore.slice(0, 8)
+  const hashAfter = props.commitAfter.slice(0, 8)
+  const linkBefore = `https://github.com/l2beat/l2beat/tree/${props.commitBefore}`
+  const linkAfter = `https://github.com/l2beat/l2beat/tree/${props.commitBefore}`
+
+  const alpineSource = readFileSync(
+    require.resolve('alpinejs/dist/cdn.min.js'),
+    'utf-8',
+  )
+
+  return `<!doctype html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8" />
+  <title>Diff @${hashAfter}</title>
+  <style>${getStyles()}</style>
+</head>
+
+<body>
+  <script>${alpineSource}</script>
+  <h1>
+    Diff between
+    <a href="${linkBefore}"><code class="removed">${hashBefore}</code></a> (main)
+    and
+    <a href="${linkAfter}"><code class="added">${hashAfter}</code></a> (PR)
+  </h1>
+  <ul>${props.diffs.map(diffToHtml).join('')}</ul>
+</body>
+
+</html>`
+}
+
+function diffToHtml(diff: ProjectDiff, _index: number, diffs: ProjectDiff[]) {
+  const initialOpen = diffs.length === 1
+
+  return `<li x-data="{ open: ${initialOpen} }">
+  <h2>
+    <button @click="open = !open" x-text="open ? 'Close' : 'Open'"></button>
+    ${diff.type === 'removed' ? `<code class="removed">[REMOVED]</code>` : ''}
+    ${diff.type === 'added' ? `<code class="added">[ADDED]</code>` : ''}
+    <span>${diff.id}</span>
+    <code><span class="added">+${diff.added}</span> <span class="removed">-${diff.removed}</span></code>
+  </h2>
+  <ul class="field" x-show="open">${diff.fields.map(fieldDiffToHtml).join('')}</ul>
+</li>`
+}
+
+function fieldDiffToHtml(
+  field: FieldDiff,
+  _index: number,
+  fields: FieldDiff[],
+) {
+  const initialOpen = fields.length === 1
+  const hasFull = field.diff.some((x) => x.type === 'full')
+
+  const expandButton = `<button x-show="open" @click="expand = !expand" x-text="expand ? 'Hide full' : 'Show full'"></button>`
+
+  return `<li x-data="{ open: ${initialOpen}, expand: false }">
+  <h3>
+    <button @click="open = !open" x-text="open ? 'Close' : 'Open'"></button>
+    ${hasFull ? expandButton : ''}
+    <span>${field.field}</span>
+    <code><span class="added">+${field.added}</span> <span class="removed">-${field.removed}</span></code>
+  </h3>
+  <pre x-show="open" class="lines" :class="expand && 'expand'"><code>${field.diff.map(lineToHtml).join('')}</code></pre>
+</li>`
+}
+
+function lineToHtml(line: LineChange, i: number, lines: LineChange[]) {
+  const separator = i === lines.length - 1 ? '' : '\n'
+  let className = line.type
+  if (line.type === 'full' && lines[i - 1]?.type !== line.type) {
+    className += ' indicator'
+  }
+  return `<span class="line ${className}">${line.value}${separator}</span>`
+}
+
+function getStyles() {
+  return `
+:root {
+  --font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
+  --font-sans: system-ui, sans-serif;
+}
+body {
+  background-color: #222;
+  color: white;
+  font-family: var(--font-sans);
+}
+.lines:not(.expand) .full {
+  display: none;
+}
+.lines:not(.expand) .full.indicator {
+  display: block;
+  font-size: 0;
+  background: repeating-linear-gradient(
+    135deg,
+    #111,
+    #111 8px,
+    #555 8px,
+    #555 16px
+  );
+}
+.lines {
+  color: #999;
+}
+.modified {
+  color: yellow;
+}
+.added {
+  background-color: #14261f;
+  color: #7ee787;
+}
+.removed {
+  background-color: #27181d;
+  color: #f14236;
+}
+button {
+  font-size: 12px;
+  font-family: var(--font-sans);
+  min-width: 60px;
+  background-color: #444;
+  font-family: inherit;
+  color: white;
+}
+pre, code {
+  font-family: var(--font-mono);
+}
+pre {
+  background-color: #111;
+  margin: 0;
+  padding: 8px;
+  font-weight: normal;
+  font-size: 12px;
+  line-height: 14px;
+  max-width: 100%;
+  overflow: auto;
+}
+h1 {
+  font-size: 24px;
+  font-weight: normal;
+  margin: 16px 0 24px 0;
+}
+h2, h3 {
+  font-size: 14px;
+  font-weight: normal;
+  padding: 8px 0;
+  margin: 0;
+  position: sticky;
+}
+h2 {
+  top: 0;
+  background-color: #222;
+}
+h3 {
+  top: 28px;
+  background-color: #222;
+}
+ul {
+  list-style-type: none;
+  padding-left: 0;
+  margin: 0;
+}
+.field {
+  margin-left: 16px;
+}
+`
+}
diff --git a/packages/config/scripts/diff/index.ts b/packages/config/scripts/diff/index.ts
new file mode 100644
index 000000000000..dec1009d5a2c
--- /dev/null
+++ b/packages/config/scripts/diff/index.ts
@@ -0,0 +1,68 @@
+import { readFileSync, writeFileSync } from 'fs'
+import { Database } from 'sqlite3'
+import { diffAll } from './diff'
+import { diffsToHtml } from './html'
+import type { Project } from './types'
+
+main()
+
+async function main() {
+  const args = process.argv.slice(2)
+  if (args.length !== 1) {
+    throw new Error('Invalid argument count')
+  }
+  const outputPath = args[0]
+
+  const dbBefore = new Database('/tmp/compare/main/db.sqlite')
+  const dbAfter = new Database('/tmp/compare/pr/db.sqlite')
+
+  const commitBefore = readFileSync('/tmp/compare/main/commit', 'utf-8').trim()
+  const commitAfter = readFileSync('/tmp/compare/pr/commit', 'utf-8').trim()
+
+  const projectBefore = (await query(dbBefore, 'SELECT * FROM projects'))
+    .map(parseProject)
+    .sort((a, b) => a.id.localeCompare(b.id))
+  const projectsAfter = (await query(dbAfter, 'SELECT * FROM projects'))
+    .map(parseProject)
+    .sort((a, b) => a.id.localeCompare(b.id))
+
+  const diffs = diffAll(projectBefore, projectsAfter)
+
+  if (diffs.length === 0) {
+    writeFileSync(outputPath, 'No changes detected')
+    return
+  }
+
+  const html = diffsToHtml({ diffs, commitBefore, commitAfter })
+  writeFileSync(outputPath, html)
+}
+
+async function query(
+  db: Database,
+  query: string,
+  values?: unknown[],
+): Promise<unknown[]> {
+  return new Promise<unknown[]>((resolve, reject) => {
+    db.all(query, values, (err: Error | null, rows: unknown[]) => {
+      if (err) reject(err)
+      resolve(rows)
+    })
+  })
+}
+
+function parseProject(project: unknown) {
+  if (typeof project !== 'object' || project === null) {
+    throw new Error('Invalid project')
+  }
+  for (const key in project) {
+    const value = Reflect.get(project, key)
+    if (typeof value === 'string') {
+      try {
+        Reflect.set(project, key, JSON.parse(value))
+      } catch {
+        Reflect.set(project, key, value)
+      }
+    }
+  }
+  return project as Project
+}
diff --git a/packages/config/scripts/diff/types.ts b/packages/config/scripts/diff/types.ts
new file mode 100644
index 000000000000..14042ba5944a
--- /dev/null
+++ b/packages/config/scripts/diff/types.ts
@@ -0,0 +1,4 @@
+export interface Project {
+  id: string
+  [value: string]: unknown
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 88c447d6ef6e..f4673dbf7533 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -279,18 +279,27 @@ importers:
       '@l2beat/typescript-config':
         specifier: workspace:*
         version: link:../typescript-config
+      '@types/diff':
+        specifier: ^7.0.2
+        version: 7.0.2
       '@types/lodash':
         specifier: ^4.17.12
         version: 4.17.12
       '@types/node-fetch':
         specifier: ^2.6.11
         version: 2.6.11
+      alpinejs:
+        specifier: ^3.14.9
+        version: 3.14.9
       chalk:
         specifier: ^4.1.2
         version: 4.1.2
       cheerio:
         specifier: ^1.0.0-rc.11
         version: 1.0.0
+      diff:
+        specifier: ^7.0.0
+        version: 7.0.0
       dotenv:
         specifier: ^16.4.5
         version: 16.4.5
@@ -2457,7 +2466,7 @@ packages:
   '@radix-ui/react-context@1.1.1':
     resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==}
     peerDependencies:
-      '@types/react': '*'
+      '@types/react': npm:types-react@19.0.0-rc.1
       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
     peerDependenciesMeta:
       '@types/react':
@@ -2514,8 +2523,8 @@ packages:
   '@radix-ui/react-dismissable-layer@1.1.1':
     resolution: {integrity: sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==}
     peerDependencies:
-      '@types/react': '*'
-      '@types/react-dom': '*'
+      '@types/react': npm:types-react@19.0.0-rc.1
+      '@types/react-dom': npm:types-react-dom@19.0.0-rc.1
       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
       react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
     peerDependenciesMeta:
@@ -2536,7 +2545,7 @@ packages:
   '@radix-ui/react-focus-guards@1.1.1':
     resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==}
     peerDependencies:
-      '@types/react': '*'
+      '@types/react': npm:types-react@19.0.0-rc.1
       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
     peerDependenciesMeta:
       '@types/react':
@@ -2615,8 +2624,8 @@ packages:
   '@radix-ui/react-popper@1.2.0':
     resolution: {integrity: sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==}
     peerDependencies:
-      '@types/react': '*'
-      '@types/react-dom': '*'
+      '@types/react': npm:types-react@19.0.0-rc.1
+      '@types/react-dom': npm:types-react-dom@19.0.0-rc.1
       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
       react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
     peerDependenciesMeta:
@@ -2641,8 +2650,8 @@ packages:
   '@radix-ui/react-portal@1.1.2':
     resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==}
     peerDependencies:
-      '@types/react': '*'
-      '@types/react-dom': '*'
+      '@types/react': npm:types-react@19.0.0-rc.1
+      '@types/react-dom': npm:types-react-dom@19.0.0-rc.1
       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
       react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
     peerDependenciesMeta:
@@ -2667,8 +2676,8 @@ packages:
   '@radix-ui/react-presence@1.1.1':
     resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==}
     peerDependencies:
-      '@types/react': '*'
-      '@types/react-dom': '*'
+      '@types/react': npm:types-react@19.0.0-rc.1
+      '@types/react-dom': npm:types-react-dom@19.0.0-rc.1
       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
       react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
     peerDependenciesMeta:
@@ -2732,8 +2741,8 @@ packages:
   '@radix-ui/react-roving-focus@1.1.0':
     resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
     peerDependencies:
-      '@types/react': '*'
-      '@types/react-dom': '*'
+      '@types/react': npm:types-react@19.0.0-rc.1
+      '@types/react-dom': npm:types-react-dom@19.0.0-rc.1
       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
       react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
     peerDependenciesMeta:
@@ -2851,7 +2860,7 @@ packages:
   '@radix-ui/react-use-controllable-state@1.1.0':
     resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==}
     peerDependencies:
-      '@types/react': '*'
+      '@types/react': npm:types-react@19.0.0-rc.1
       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
     peerDependenciesMeta:
       '@types/react':
@@ -2914,7 +2923,7 @@ packages:
   '@radix-ui/react-use-size@1.1.0':
     resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==}
     peerDependencies:
-      '@types/react': '*'
+      '@types/react': npm:types-react@19.0.0-rc.1
       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
     peerDependenciesMeta:
       '@types/react':
@@ -2923,8 +2932,8 @@ packages:
   '@radix-ui/react-visually-hidden@1.1.0':
     resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==}
     peerDependencies:
-      '@types/react': '*'
-      '@types/react-dom': '*'
+      '@types/react': npm:types-react@19.0.0-rc.1
+      '@types/react-dom': npm:types-react-dom@19.0.0-rc.1
       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
       react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
     peerDependenciesMeta:
@@ -3360,6 +3369,9 @@ packages:
   '@types/deep-diff@1.0.5':
     resolution: {integrity: sha512-PQyNSy1YMZU1hgZA5tTYfHPpUAo9Dorn1PZho2/budQLfqLu3JIP37JAavnwYpR1S2yFZTXa3hxaE4ifGW5jaA==}
 
+  '@types/diff@7.0.2':
+    resolution: {integrity: sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==}
+
   '@types/emscripten@1.40.0':
     resolution: {integrity: sha512-MD2JJ25S4tnjnhjWyalMS6K6p0h+zQV6+Ylm+aGbiS8tSn/aHLSGNzBgduj6FB4zH0ax2GRMGYi/8G1uOxhXWA==}
 
@@ -3613,6 +3625,12 @@ packages:
     peerDependencies:
       vite: ^4 || ^5
 
+  '@vue/reactivity@3.1.5':
+    resolution: {integrity: sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==}
+
+  '@vue/shared@3.1.5':
+    resolution: {integrity: sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==}
+
   abbrev@1.1.1:
     resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
 
@@ -3684,6 +3702,9 @@ packages:
   ajv@6.12.6:
     resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
 
+  alpinejs@3.14.9:
+    resolution: {integrity: sha512-gqSOhTEyryU9FhviNqiHBHzgjkvtukq9tevew29fTj+ofZtfsYriw4zPirHHOAy9bw8QoL3WGhyk7QqCh5AYlw==}
+
   ansi-colors@4.1.3:
     resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
     engines: {node: '>=6'}
@@ -4417,6 +4438,10 @@ packages:
     resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
     engines: {node: '>=0.3.1'}
 
+  diff@7.0.0:
+    resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
+    engines: {node: '>=0.3.1'}
+
   dir-glob@3.0.1:
     resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
     engines: {node: '>=8'}
@@ -10260,6 +10285,8 @@ snapshots:
 
   '@types/deep-diff@1.0.5': {}
 
+  '@types/diff@7.0.2': {}
+
   '@types/emscripten@1.40.0': {}
 
   '@types/eslint-plugin-mocha@10.4.0':
@@ -10568,6 +10595,12 @@ snapshots:
     transitivePeerDependencies:
       - '@swc/helpers'
 
+  '@vue/reactivity@3.1.5':
+    dependencies:
+      '@vue/shared': 3.1.5
+
+  '@vue/shared@3.1.5': {}
+
   abbrev@1.1.1:
     optional: true
 
@@ -10629,6 +10662,10 @@ snapshots:
       json-schema-traverse: 0.4.1
       uri-js: 4.4.1
 
+  alpinejs@3.14.9:
+    dependencies:
+      '@vue/reactivity': 3.1.5
+
   ansi-colors@4.1.3: {}
 
   ansi-escapes@4.3.2:
@@ -11394,6 +11431,8 @@ snapshots:
 
   diff@5.2.0: {}
 
+  diff@7.0.0: {}
+
   dir-glob@3.0.1:
     dependencies:
       path-type: 4.0.0
@@ -14873,7 +14912,7 @@ snapshots:
       '@tsconfig/node14': 1.0.3
       '@tsconfig/node16': 1.0.4
       '@types/node': 22.8.2
-      acorn: 8.14.0
+      acorn: 8.14.1
       acorn-walk: 8.3.4
       arg: 4.1.3
       create-require: 1.1.1


Reviews:
user: login: mateuszradomski
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T07:29:39Z

Review comments:
diff_hunk: @@ -0,0 +1,172 @@
+import { readFileSync } from 'fs'
+import type { FieldDiff, LineChange, ProjectDiff } from './diff'
+
+export function diffsToHtml(props: {
+  diffs: ProjectDiff[]
+  commitBefore: string
+  commitAfter: string
+}) {
+  const hashBefore = props.commitBefore.slice(0, 8)
+  const hashAfter = props.commitAfter.slice(0, 8)
+
+  const alpineSource = readFileSync(
+    require.resolve('alpinejs/dist/cdn.min.js'),
+    'utf-8',
+  )
+
+  return `<!doctype html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8" />
+  <title>Diff @${hashAfter}</title>
+  <style>${getStyles()}</style>
+</head>
+
+<body>
+  <script>${alpineSource}</script>
+  <h1>
+    Diff between
+    <code class="removed">${hashBefore}</code> (main)
path: packages/config/scripts/diff/html.ts
user: login: mateuszradomski
body: Suggestion: Make this a link to the commit on GH?
created_at: 2025-04-04T07:23:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,159 @@
+import { type Change, diffLines } from 'diff'
+import type { Project } from './types'
+
+export interface ProjectDiff {
+  id: string
+  type: 'added' | 'removed' | 'modified'
+  fields: FieldDiff[]
+  added: number
+  removed: number
+}
+
+export interface FieldDiff {
+  field: string
+  diff: LineChange[]
+  added: number
+  removed: number
+}
+
+export interface LineChange {
+  type: 'full' | 'ctx' | 'added' | 'removed'
+  value: string
+}
+
+export function diffAll(
+  projectsBefore: Project[],
+  projectsAfter: Project[],
+): ProjectDiff[] {
+  const result: ProjectDiff[] = []
+
+  let beforeIndex = 0
+  let afterIndex = 0
+  while (
+    beforeIndex < projectsBefore.length ||
+    afterIndex < projectsAfter.length
+  ) {
+    const before = projectsBefore[beforeIndex]
+    const after = projectsAfter[afterIndex]
+
+    if (!before) {
path: packages/config/scripts/diff/diff.ts
user: login: mateuszradomski
body: What will happen if in the following scenario

```
| before projects | after projects |
|-----------------|----------------|
|        arbitrum |       arbitrum |
|        starknet |                |
|            zora |           zora |
```

We want to see `starknet` as removed but since we iterate over indexes we're going to show a diff for starknet between it and zora plus later we're going to say that zora was removed. Or am i mistaken?
created_at: 2025-04-04T07:27:47Z
author_association: CONTRIBUTOR
path: packages/config/scripts/diff/diff.ts
user: login: mateuszradomski
body: We need a clear stance on where each script goes. I think that this makes sense to be here, but other stuff inside this folder does not belong
created_at: 2025-04-04T07:29:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-04-02T12:18:49Z
body: [vc]: #cvhxA7nAI7rdJn/e97meRCIG7Udd6+wiajALwy4heKA=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJsMmJlYXQtc3RhZ2luZyIsInJvb3REaXJlY3RvcnkiOiJwYWNrYWdlcy9mcm9udGVuZCIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9sMmJlYXQvbDJiZWF0LXN0YWdpbmcvN21wV1pEVWcycVVzUjNTM3FBbkFtOGZMWUFNcSIsInByZXZpZXdVcmwiOiJsMmJlYXQtc3RhZ2luZy1naXQtY29uZmlnLWRpZmYtbDJiZWF0LnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifX0seyJuYW1lIjoibDJiZWF0LXByb2R1Y3Rpb24iLCJyb290RGlyZWN0b3J5IjoicGFja2FnZXMvZnJvbnRlbmQiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbDJiZWF0L2wyYmVhdC1wcm9kdWN0aW9uL0o5WEo4Y2preFp2b0F2TTFvaGRuTHYxQXlMcVkiLCJwcmV2aWV3VXJsIjoibDJiZWF0LXByb2R1Y3Rpb24tZ2l0LWNvbmZpZy1kaWZmLWwyYmVhdC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IklHTk9SRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn19LHsibmFtZSI6InByb3RvY29sYmVhdCIsInJvb3REaXJlY3RvcnkiOiJwYWNrYWdlcy9wcm90b2NvbGJlYXQiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbDJiZWF0L3Byb3RvY29sYmVhdC8zcW9RaXhicjlyUUNWTGcyV2o2TkVjV1FvS0N0IiwicHJldmlld1VybCI6InByb3RvY29sYmVhdC1naXQtY29uZmlnLWRpZmYtbDJiZWF0LnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJwcm90b2NvbGJlYXQtZ2l0LWNvbmZpZy1kaWZmLWwyYmVhdC52ZXJjZWwuYXBwIn19LHsibmFtZSI6InRvb2xzIiwicm9vdERpcmVjdG9yeSI6InBhY2thZ2VzL3Rvb2xzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2wyYmVhdC90b29scy9Gd0ZhV1JaWEVkU2RtZ0I3Znk4TWVKcTJ6dVU4IiwicHJldmlld1VybCI6InRvb2xzLWdpdC1jb25maWctZGlmZi1sMmJlYXQudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InRvb2xzLWdpdC1jb25maWctZGlmZi1sMmJlYXQudmVyY2VsLmFwcCJ9fSx7Im5hbWUiOiJ1b3BzLWRhc2hib2FyZCIsInJvb3REaXJlY3RvcnkiOiJwYWNrYWdlcy91b3BzLWRhc2hib2FyZCIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9sMmJlYXQvdW9wcy1kYXNoYm9hcmQvMndMSmFTcUd5NVFHSzI2Z3pCN2htaEYxZURVaCIsInByZXZpZXdVcmwiOiJ1b3BzLWRhc2hib2FyZC1naXQtY29uZmlnLWRpZmYtbDJiZWF0LnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJ1b3BzLWRhc2hib2FyZC1naXQtY29uZmlnLWRpZmYtbDJiZWF0LnZlcmNlbC5hcHAifX1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)


<details><summary>5 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **l2beat-production** | ⬜️ Ignored ([Inspect](https://vercel.com/l2beat/l2beat-production/J9XJ8cjkxZvoAvM1ohdnLv1AyLqY)) | [Visit Preview](https://l2beat-production-git-config-diff-l2beat.vercel.app) |  | Apr 4, 2025 9:40am |
| **l2beat-staging** | ⬜️ Ignored ([Inspect](https://vercel.com/l2beat/l2beat-staging/7mpWZDUg2qUsR3S3qAnAm8fLYAMq)) | [Visit Preview](https://l2beat-staging-git-config-diff-l2beat.vercel.app) |  | Apr 4, 2025 9:40am |
| **protocolbeat** | ⬜️ Ignored ([Inspect](https://vercel.com/l2beat/protocolbeat/3qoQixbr9rQCVLg2Wj6NEcWQoKCt)) | [Visit Preview](https://protocolbeat-git-config-diff-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/protocolbeat-git-config-diff-l2beat.vercel.app?via=pr-comment-feedback-link) | Apr 4, 2025 9:40am |
| **tools** | ⬜️ Ignored ([Inspect](https://vercel.com/l2beat/tools/FwFaWRZXEdSdmgB7fy8MeJq2zuU8)) | [Visit Preview](https://tools-git-config-diff-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/tools-git-config-diff-l2beat.vercel.app?via=pr-comment-feedback-link) | Apr 4, 2025 9:40am |
| **uops-dashboard** | ⬜️ Ignored ([Inspect](https://vercel.com/l2beat/uops-dashboard/2wLJaSqGy5QGK26gzB7hmhF1eDUh)) | [Visit Preview](https://uops-dashboard-git-config-diff-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/uops-dashboard-git-config-diff-l2beat.vercel.app?via=pr-comment-feedback-link) | Apr 4, 2025 9:40am |
</details>






author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-02T12:47:49Z
body: ## Config Changes Report

No schema changes detected between main and this PR.

*Last updated: 2025-04-04 09:41:09 UTC*
author_association: CONTRIBUTOR
user: login: socket-security[bot]
created_at: 2025-04-02T14:50:58Z
body: **New and removed dependencies detected.** Learn more about [Socket for GitHub ↗︎](https://socket.dev?utm_medium=gh)


| Package | New capabilities | Transitives | Size | Publisher |
|:--- |:--- |:--- |:--- |:--- |
| [npm/@types/diff@7.0.2](https://socket.dev/npm/package/@types/diff/overview/7.0.2) | None | <a href="https://socket.dev/npm/package/@types/diff/dependencies/7.0.2">`0`</a> | 20.4 kB | <a href="https://socket.dev/npm/user/types">types</a> |
| [npm/alpinejs@3.14.9](https://socket.dev/npm/package/alpinejs/overview/3.14.9) | Transitive: <a href="https://socket.dev/npm/package/alpinejs/alerts/3.14.9?tab=dependencies&alert_name=envVars">environment</a> | <a href="https://socket.dev/npm/package/alpinejs/dependencies/3.14.9">`+2`</a> | 811 kB | <a href="https://socket.dev/npm/user/calebporzio">calebporzio</a> |
| [npm/diff@7.0.0](https://socket.dev/npm/package/diff/overview/7.0.0) | None | <a href="https://socket.dev/npm/package/diff/dependencies/7.0.0">`0`</a> | 621 kB | <a href="https://socket.dev/npm/user/explodingcabbage">explodingcabbage</a> |


**🚮 Removed packages:** [npm/js-cookie@3.0.5](https://socket.dev/npm/package/js-cookie/overview/3.0.5)



[**View full report↗︎**](https://socket.dev/dashboard/org/l2beat/diff/5ec898cb-3dad-496c-8bef-626c0fd0cd60/11362767-5c5c-46df-b7e8-fb70f8082a6b)

<!-- overview-comment -->
author_association: NONE