-
-
Notifications
You must be signed in to change notification settings - Fork 132
fix(ci): stop flaky shard reds and cut wasted runner minutes #2915
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9f4ce0f
6a2d6bd
2d60849
3622194
f25a923
d1dd199
54d14ff
e85da89
f7ca9fe
b7b4b03
932e478
98b782e
af69553
6b2ee04
4fbdcc7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,10 +19,12 @@ function hasSupabaseCli(): boolean { | |
| function getLocalSupabaseCli(repoRoot: string): string | null { | ||
| const binName = process.platform === 'win32' ? 'supabase.exe' : 'supabase' | ||
| // Prefer the package shim Bun/npm expose on PATH (.bin), then legacy bin/ layouts. | ||
| // Do NOT use dist/supabase.js — that is the installer stub and throws | ||
| // "No matching Supabase CLI binary package" when the platform binary is not | ||
| // extracted yet (common while `bun install` still runs in parallel with start). | ||
| const candidates = [ | ||
| resolve(repoRoot, 'node_modules', '.bin', binName), | ||
| resolve(repoRoot, 'node_modules', 'supabase', 'bin', binName), | ||
| resolve(repoRoot, 'node_modules', 'supabase', 'dist', 'supabase.js'), | ||
| ] | ||
| return candidates.find(candidate => existsSync(candidate)) ?? null | ||
| } | ||
|
|
@@ -322,6 +324,28 @@ function isTransientDockerPortBindFailure(output: string): boolean { | |
| || /failed to bind host port/i.test(output) | ||
| } | ||
|
|
||
| function getCloudflareWorkerPorts(): number[] { | ||
| const raw = process.env.CLOUDFLARE_WORKER_PORT_OFFSET | ||
| if (!raw || !/^\d+$/.test(raw)) | ||
| return [] | ||
|
|
||
| const offset = Number(raw) | ||
| // Match scripts/start-cloudflare-workers.sh / cloudflare-test-config.ts so we never | ||
| // freeHostPorts() derived ports from an invalid offset before the launcher rejects it. | ||
| if (!Number.isSafeInteger(offset) || offset < 0 || offset > 50_000) | ||
| return [] | ||
|
|
||
| // wrangler worker ports + inspector ports from scripts/start-cloudflare-workers.sh | ||
| return [ | ||
| 8787 + offset, | ||
| 8788 + offset, | ||
| 8789 + offset, | ||
| 9230 + offset, | ||
| 9231 + offset, | ||
| 9232 + offset, | ||
| ] | ||
|
Comment on lines
+338
to
+346
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Confirm the wrangler worker and inspector base ports used by the startup script.
fd -t f 'start-cloudflare-workers.sh' -x cat -n {}
fd -t f 'cloudflare-test-config.ts' -x rg -n '87[0-9]{2}|92[0-9]{2}|PORT' {}Repository: Cap-go/capgo.app Length of output: 9245 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- scripts/cloudflare-test-config.ts ---'
cat -n scripts/cloudflare-test-config.ts | sed -n '1,90p'
printf '%s\n' '--- scripts/supabase-worktree.ts ---'
cat -n scripts/supabase-worktree.ts | sed -n '300,355p'
printf '%s\n' '--- port helper usages ---'
rg -n -C 3 'getCloudflareWorkerPorts|cloudflareWorkerPortOffset|API_INSPECTOR_PORT|PLUGIN_INSPECTOR_PORT|FILES_INSPECTOR_PORT' scriptsRepository: Cap-go/capgo.app Length of output: 8465 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- cloudflareWorkerUrl call sites ---'
rg -n -C 2 'cloudflareWorkerUrl\(' --glob '!node_modules/**'
printf '%s\n' '--- all Cloudflare worker port literals ---'
rg -n -g '*.ts' -g '*.tsx' -g '*.vue' -g '*.sh' '8787|8788|8789|9230|9231|9232'
printf '%s\n' '--- offset bounds in port reservation ---'
cat -n scripts/supabase-worktree.ts | sed -n '354,380p'
cat -n scripts/supabase-worktree.ts | sed -n '450,475p'Repository: Cap-go/capgo.app Length of output: 193 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- all Cloudflare worker port literals ---'
rg -n -g '*.ts' -g '*.tsx' -g '*.vue' -g '*.sh' '8787|8788|8789|9230|9231|9232' . || true
printf '%s\n' '--- port reservation call sites ---'
cat -n scripts/supabase-worktree.ts | sed -n '354,380p'
cat -n scripts/supabase-worktree.ts | sed -n '450,475p'Repository: Cap-go/capgo.app Length of output: 4659 Centralize the Cloudflare port constants. The ports match 🧰 Tools🪛 ast-grep (0.45.0)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Keep worktree host ports out of the kernel ephemeral pool. | ||
| * | ||
|
|
@@ -336,11 +360,14 @@ function reserveWorktreePortsFromEphemeralPool(repoRoot: string): void { | |
| return | ||
|
|
||
| const { cfg } = ensureWorktreeSupabaseDir(repoRoot) | ||
| const ports = Object.values(cfg.ports).filter(port => Number.isFinite(port)).sort((a, b) => a - b) | ||
| const ports = [ | ||
| ...Object.values(cfg.ports).filter(port => Number.isFinite(port)), | ||
| ...getCloudflareWorkerPorts(), | ||
| ].sort((a, b) => a - b) | ||
| if (ports.length === 0) | ||
| return | ||
|
|
||
| const reserved = ports.join(',') | ||
| const reserved = [...new Set(ports)].join(',') | ||
| const result = spawnSync('sudo', ['sysctl', '-w', `net.ipv4.ip_local_reserved_ports=${reserved}`], { | ||
| encoding: 'utf8', | ||
| }) | ||
|
|
@@ -356,24 +383,92 @@ function reserveWorktreePortsFromEphemeralPool(repoRoot: string): void { | |
| console.error(`Reserved Supabase worktree ports from ephemeral pool: ${reserved}`) | ||
| } | ||
|
|
||
| /** | ||
| * Drop whatever still holds worktree host ports after a partial Docker start. | ||
| * | ||
| * `fuser` alone is not enough on GitHub runners: docker-proxy / leftover | ||
| * containers from a failed bind can keep the port until removed explicitly. | ||
| */ | ||
| function freeHostPorts(ports: number[]): void { | ||
| if (process.platform === 'win32' || ports.length === 0) | ||
| return | ||
|
|
||
| for (const port of ports) { | ||
| const uniquePorts = [...new Set(ports.filter(port => Number.isFinite(port)))] | ||
| const holders = new Set<string>() | ||
|
|
||
| for (const port of uniquePorts) { | ||
| const byPublish = spawnSync('docker', ['ps', '-aq', '--filter', `publish=${port}`], { | ||
| encoding: 'utf8', | ||
| }) | ||
| for (const id of (byPublish.stdout ?? '').split(/\s+/).filter(Boolean)) | ||
| holders.add(id) | ||
| } | ||
|
|
||
| // Match host-port publish strings docker prints (0.0.0.0:58722->5432/tcp). | ||
| const listed = spawnSync('docker', ['ps', '-a', '--format', '{{.ID}} {{.Ports}}'], { | ||
| encoding: 'utf8', | ||
| }) | ||
| if ((listed.status ?? 1) === 0) { | ||
| for (const line of (listed.stdout ?? '').split('\n')) { | ||
| const trimmed = line.trim() | ||
| if (!trimmed) | ||
| continue | ||
| const spaceIdx = trimmed.indexOf(' ') | ||
| const id = spaceIdx >= 0 ? trimmed.slice(0, spaceIdx) : trimmed | ||
| const published = spaceIdx >= 0 ? trimmed.slice(spaceIdx + 1) : '' | ||
| if (uniquePorts.some(port => published.includes(`:${port}->`) || published.includes(`:${port}/`))) | ||
| holders.add(id) | ||
| } | ||
| } | ||
|
|
||
| if (holders.size > 0) { | ||
| console.error(`Removing Docker containers still publishing worktree ports: ${[...holders].join(', ')}`) | ||
| spawnSync('docker', ['rm', '-f', ...holders], { stdio: 'inherit' }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Starting a worktree can destroy an unrelated Docker container that happens to publish one of these ports because this cleanup force-removes all matching container IDs without checking the current Prompt for AI agents |
||
| } | ||
|
|
||
| for (const port of uniquePorts) { | ||
| spawnSync('fuser', ['-k', `${port}/tcp`], { stdio: 'ignore' }) | ||
| // Close lingering sockets that still occupy the port after docker-proxy dies. | ||
| spawnSync('ss', ['-K', 'sport', '=', `:${port}`], { stdio: 'ignore' }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The newly added Prompt for AI agents |
||
| } | ||
| } | ||
|
Comment on lines
392
to
434
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Restrict container removal to CI or to this worktree's containers.
🛡️ Proposed guard-function freeHostPorts(ports: number[]): void {
+function freeHostPorts(ports: number[]): void {
if (process.platform === 'win32' || ports.length === 0)
return
+ // Removing containers by host port can hit unrelated local stacks; only do it in CI.
+ const allowContainerRemoval = Boolean(process.env.CI)
const uniquePorts = [...new Set(ports.filter(port => Number.isFinite(port)))]
const holders = new Set<string>()
+ if (allowContainerRemoval) {
for (const port of uniquePorts) {
...
}
+ }🧰 Tools🪛 ast-grep (0.45.0)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * Cancelled CI jobs can leave named Supabase containers that still hold host | ||
| * ports after `supabase stop`. Force-remove anything matching this worktree. | ||
| */ | ||
| function removeLeftoverWorktreeContainers(projectId: string): void { | ||
| if (process.platform === 'win32') | ||
| return | ||
|
|
||
| const listed = spawnSync('docker', ['ps', '-aq', '--filter', `name=${projectId}`], { | ||
| encoding: 'utf8', | ||
| }) | ||
| if ((listed.status ?? 1) !== 0) | ||
| return | ||
|
|
||
| const ids = (listed.stdout ?? '').split(/\s+/).filter(Boolean) | ||
| if (ids.length === 0) | ||
| return | ||
|
|
||
| console.error(`Removing leftover Docker containers for ${projectId}: ${ids.join(', ')}`) | ||
| spawnSync('docker', ['rm', '-f', ...ids], { stdio: 'inherit' }) | ||
| } | ||
|
|
||
| /** | ||
| * `supabase start` can fail on GitHub runners with a transient Docker port bind | ||
| * (`address already in use`) after a partial start/stop. Retry only that class of | ||
| * failure so permanent start errors fail fast. | ||
| */ | ||
| function runSupabaseStartWithRetry(args: string[], repoRoot: string): number { | ||
| const { cfg } = ensureWorktreeSupabaseDir(repoRoot) | ||
| const ports = Object.values(cfg.ports).filter(port => Number.isFinite(port)) | ||
| const ports = [ | ||
| ...Object.values(cfg.ports).filter(port => Number.isFinite(port)), | ||
| ...getCloudflareWorkerPorts(), | ||
| ] | ||
| reserveWorktreePortsFromEphemeralPool(repoRoot) | ||
| removeLeftoverWorktreeContainers(cfg.projectId) | ||
| freeHostPorts(ports) | ||
|
|
||
| const maxAttempts = 5 | ||
| for (let attempt = 1; attempt <= maxAttempts; attempt++) { | ||
|
|
@@ -385,8 +480,14 @@ function runSupabaseStartWithRetry(args: string[], repoRoot: string): number { | |
| return status | ||
| console.error(`Supabase start hit a transient Docker port bind (attempt ${attempt}/${maxAttempts}); stopping and retrying...`) | ||
| runSupabase(['stop', '--no-backup'], repoRoot) | ||
| removeLeftoverWorktreeContainers(cfg.projectId) | ||
| freeHostPorts(ports) | ||
| spawnSync(process.platform === 'win32' ? 'timeout' : 'sleep', process.platform === 'win32' ? ['/T', '2', '/NOBREAK'] : ['2']) | ||
| // Back off so docker-proxy / TIME_WAIT can release before the next bind. | ||
| const sleepSeconds = String(Math.min(2 ** attempt, 8)) | ||
| spawnSync( | ||
| process.platform === 'win32' ? 'timeout' : 'sleep', | ||
| process.platform === 'win32' ? ['/T', sleepSeconds, '/NOBREAK'] : [sleepSeconds], | ||
| ) | ||
| } | ||
| return 1 | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import { randomUUID } from 'node:crypto' | ||
| import { afterAll, beforeAll, describe, expect, it } from 'vitest' | ||
| import { BASE_URL, ORG_ID_CRON_APP, STRIPE_CUSTOMER_ID_CRON_APP, getSupabaseClient, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats } from './test-utils.ts' | ||
| import { BASE_URL, ORG_ID_CRON_APP, STRIPE_CUSTOMER_ID_CRON_APP, getSupabaseClient, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats, warmEdgeEndpoint } from './test-utils.ts' | ||
|
|
||
| const appId = `com.cron.${randomUUID().slice(0, 8)}` | ||
|
|
||
|
|
@@ -24,6 +24,12 @@ describe('[POST] /triggers/cron_stat_app', () => { | |
| .eq('id', ORG_ID_CRON_APP) | ||
| if (error) | ||
| throw error | ||
|
|
||
| await warmEdgeEndpoint('/triggers/cron_stat_app', { | ||
| method: 'POST', | ||
| headers: triggerHeaders, | ||
| body: JSON.stringify({ appId, orgId: ORG_ID_CRON_APP }), | ||
| }) | ||
|
Comment on lines
+28
to
+32
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Inspect the cron_stat_app test setup and the trigger's write behavior.
fd -t f 'cron_stat_app.test.ts' -x sed -n '1,80p' {}
fd -t f -g '*cron_stat_app*' supabase | head -20Repository: Cap-go/capgo.app Length of output: 2678 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- test file ---'
sed -n '1,220p' tests/cron_stat_app.test.ts
printf '%s\n' '--- trigger outline/source ---'
wc -l supabase/functions/_backend/triggers/cron_stat_app.ts
sed -n '1,260p' supabase/functions/_backend/triggers/cron_stat_app.ts
printf '%s\n' '--- reset helpers ---'
rg -n -A35 -B10 'resetAndSeedAppDataStats|resetAppDataStats|resetAndSeedAppData|warmEdgeEndpoint' tests/test-utils.ts testsRepository: Cap-go/capgo.app Length of output: 50373 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- cron_stat_app.test.ts ---'
nl -ba tests/cron_stat_app.test.ts | sed -n '1,180p'
printf '%s\n' '--- cron_stat_app.ts symbols and writes ---'
rg -n '^(export )?(async )?function|from\\(|insert|update|upsert|delete|rpc|queue|stats|return' supabase/functions/_backend/triggers/cron_stat_app.ts
printf '%s\n' '--- cron_stat_app.ts relevant sections ---'
nl -ba supabase/functions/_backend/triggers/cron_stat_app.ts | sed -n '1,240p'
printf '%s\n' '--- helper definitions ---'
rg -n '^(export )?async function (resetAndSeedAppDataStats|resetAppDataStats|resetAndSeedAppData|resetAppData|warmEdgeEndpoint)' tests/test-utils.tsRepository: Cap-go/capgo.app Length of output: 411 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- cron_stat_app.test.ts ---'
awk '{printf "%5d %s\n", NR, $0}' tests/cron_stat_app.test.ts | sed -n '1,180p'
printf '%s\n' '--- cron_stat_app.ts symbols and writes ---'
rg -n 'function|from\(|insert|update|upsert|delete|rpc|queue|stats|return' supabase/functions/_backend/triggers/cron_stat_app.ts || true
printf '%s\n' '--- cron_stat_app.ts relevant sections ---'
awk '{printf "%5d %s\n", NR, $0}' supabase/functions/_backend/triggers/cron_stat_app.ts | sed -n '1,260p'
printf '%s\n' '--- helper definitions ---'
rg -n 'resetAndSeedAppDataStats|resetAppDataStats|resetAndSeedAppData|resetAppData|warmEdgeEndpoint' tests/test-utils.ts | head -80Repository: Cap-go/capgo.app Length of output: 20695 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- trigger handler ---'
sed -n '480,690p' supabase/functions/_backend/triggers/cron_stat_app.ts
printf '%s\n' '--- warm-up helper ---'
sed -n '490,535p' tests/test-utils.ts
printf '%s\n' '--- seed and reset helpers ---'
sed -n '650,750p' tests/test-utils.ts
printf '%s\n' '--- stats table references in helpers and migrations ---'
rg -n 'daily_mau|daily_bandwidth|daily_storage|daily_version|daily_storage_hourly|app_metrics_cache|stats_updated_at|plan_calculated_at' tests/test-utils.ts supabase/migrations | head -160Repository: Cap-go/capgo.app Length of output: 40219 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- seed/reset SQL definitions ---'
rg -n -A90 -B10 'reset_and_seed_app_stats_data|reset_app_stats_data|reset_and_seed_app_data\\(' supabase/migrations | head -420
printf '%s\n' '--- queue and stats-refresh SQL definitions ---'
rg -n -A80 -B15 'queue_cron_stat_org_for_org|mark_app_stats_refreshed|sync.*stats|plan_calculated_at' supabase/migrations | head -500
printf '%s\n' '--- assertions or reads of affected stats rows in the test ---'
rg -n 'daily_|storage_hourly|stats_updated_at|plan_calculated_at|queue_cron|rpc\\(' tests/cron_stat_app.test.tsRepository: Cap-go/capgo.app Length of output: 48896 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- seed/reset function locations ---'
for term in reset_and_seed_app_stats_data reset_app_stats_data reset_and_seed_app_data; do
printf '\n[%s]\n' "$term"
rg -n -F "$term" supabase/migrations tests/test-utils.ts | head -30
done
printf '%s\n' '--- queue function and refresh logic ---'
sed -n '10760,10855p' supabase/migrations/20260708000000_prod_baseline.sql
printf '%s\n' '--- test affected-state references ---'
for term in 'daily_' 'storage_hourly' 'stats_updated_at' 'plan_calculated_at' 'queue_cron' 'rpc('; do
printf '\n[%s]\n' "$term"
rg -n -F "$term" tests/cron_stat_app.test.ts || true
done
printf '%s\n' '--- targeted SQL extraction ---'
python3 - <<'PY'
from pathlib import Path
text = Path('supabase/migrations/20260708000000_prod_baseline.sql').read_text()
terms = ('reset_and_seed_app_stats_data', 'reset_app_stats_data', 'reset_and_seed_app_data')
for term in terms:
pos = text.find(term)
print(f'\n--- {term}: offset {pos} ---')
if pos >= 0:
start = max(0, text.rfind('CREATE OR REPLACE FUNCTION', 0, pos) - 100)
end = text.find('ALTER FUNCTION', pos)
print(text[start:end if end >= 0 else pos + 6000][:12000])
PYRepository: Cap-go/capgo.app Length of output: 4275 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- locate reset function definitions across the repository ---'
rg -n -F 'reset_and_seed_app_stats_data' . || true
rg -n -F 'reset_app_stats_data' . || true
rg -n -F 'CREATE OR REPLACE FUNCTION' supabase tests | rg 'reset|seed' | head -120
printf '%s\n' '--- locate sync and pending-refresh definitions ---'
rg -n -F 'syncOrgStatsRefresh' supabase/functions/_backend/triggers/cron_stat_app.ts
sed -n '350,490p' supabase/functions/_backend/triggers/cron_stat_app.ts
rg -n -F 'has_pending_app_stats_refresh' supabase/migrations supabase | head -30
printf '%s\n' '--- identify all assertions in the test ---'
python3 - <<'PY'
from pathlib import Path
for i, line in enumerate(Path('tests/cron_stat_app.test.ts').read_text().splitlines(), 1):
if any(x in line for x in ('expect(', '.select(', '.update(', '.rpc(')):
print(f'{i}: {line}')
PYRepository: Cap-go/capgo.app Length of output: 10009 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- reset_app_stats_data ---'
sed -n '1110,1165p' supabase/seed.sql
printf '%s\n' '--- reset_and_seed_app_stats_data ---'
sed -n '1150,1228p' supabase/seed.sql
printf '%s\n' '--- reset_and_seed_app_data ---'
sed -n '895,1015p' supabase/seed.sqlRepository: Cap-go/capgo.app Length of output: 11336 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- shared cron test constants and usages ---'
rg -n -F 'ORG_ID_CRON_APP' tests | head -120
printf '%s\n' '--- stats trigger test constants ---'
rg -n -F 'STRIPE_CUSTOMER_ID_CRON_APP' tests | head -120Repository: Cap-go/capgo.app Length of output: 1615 Use an inert warm-up payload. This payload executes the trigger and mutates daily stats, refresh timestamps, and the plan-refresh queue before the tests run. Use 🤖 Prompt for AI Agents |
||
| }) | ||
|
|
||
| afterAll(async () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Reserve the default Cloudflare port band.
Line 333 states that this logic matches the launcher, but an unset
CLOUDFLARE_WORKER_PORT_OFFSETreturns[]here. The launcher treats an unset value as offset0and binds ports8787through8789and9230through9232. A default worker run can therefore collide with an existing process that this cleanup did not reserve or release.Normalize an unset or empty value to
0. Keep[]for malformed or out-of-range values.Proposed fix
function getCloudflareWorkerPorts(): number[] { const raw = process.env.CLOUDFLARE_WORKER_PORT_OFFSET - if (!raw || !/^\d+$/.test(raw)) + if (raw && !/^\d+$/.test(raw)) return [] - const offset = Number(raw) + const offset = raw ? Number(raw) : 0🧰 Tools
🪛 ast-grep (0.45.0)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawnSync } from 'node:child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents