<!-- THIS FILE IS GENERATED by scripts/build-docs.mjs. Do not edit by hand. -->
<!-- To change scanner descriptions, edit lib/scanners/*.mjs metadata exports. -->
<!-- To change gate thresholds, edit lib/gates/*.mjs metadata exports. -->

# Scanner patterns

AST/grep-style scanners run in parallel with metric-driven investigation. They find known anti-patterns. Findings on cold-path or unmappable files are dropped unless the scanner declares `trafficIndependent: true`.

Total scanners: 15.

## Patterns

### `cache-components-suspense-dedupe` — 'use cache' with multiple Suspense boundaries on the same data

- **Severity**: medium
- **Billing dimension**: function-duration
- **Traffic-independent**: no (cold-path findings get dropped)

**Description.** Default `'use cache'` does not dedupe identical calls across separate `<Suspense>` boundaries on the same render. Each boundary re-invokes the cached function, multiplying function-duration cost and inflating ISR write churn when the output is large.

**Fix.** Hoist the promise to the page level (`const dataPromise = fetchData()` at the top, passed down to each Suspense child) OR move the shared fetch into a `'use cache: remote'` data-access layer so cross-request and cross-boundary dedupe applies.

**Citations:**
- `https://nextjs.org/docs/app/api-reference/directives/use-cache`
- `https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents`
- `https://nextjs.org/docs/app/guides/migrating-to-cache-components`

---

### `edge-heavy-import` — Heavy / node-only import inside edge-runtime file

- **Severity**: high
- **Billing dimension**: function-duration
- **Traffic-independent**: yes (cold-path findings survive the doctrine drop)

**Description.** Edge runtime is a constrained sandbox with no node: builtins and a much smaller cold-start budget than Node functions. Heavy SDKs (sharp, @aws-sdk/*, @prisma/client, pg, puppeteer) either fail at deploy or inflate cold-start latency. Move the import to a Node runtime function, or replace with an edge-compatible alternative (e.g., neon-driver instead of pg).

**Fix.** Either (a) drop the `export const runtime = 'edge'` so the route runs on Node (default in 2026), or (b) replace the heavy import with an edge-compatible alternative. For DB: use @neondatabase/serverless or @planetscale/database instead of pg/mysql2. For image: do the work in a Node route handler. For auth signing: use jose (Web Crypto) instead of jsonwebtoken.

**Citations:**
- `https://vercel.com/docs/functions/runtimes/edge-runtime`
- `https://vercel.com/docs/fluid-compute`

---

### `force-dynamic` — export const dynamic = 'force-dynamic'

- **Severity**: medium
- **Billing dimension**: function-duration
- **Traffic-independent**: no (cold-path findings get dropped)

**Description.** force-dynamic disables static + ISR rendering. The route runs the function on every request. Sometimes necessary (cookies, headers, real-time data), often a habit that costs function-duration and edge-requests at scale.

**Fix.** Audit the route. If dynamic behavior comes from cookies()/headers()/searchParams, force-dynamic may be redundant — Next infers dynamic automatically. Consider revalidate / 'use cache' / generateStaticParams if any portion can be pre-rendered.

**Citations:**
- `https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config`

---

### `headers-in-page` — Dynamic API call forcing dynamic rendering

- **Severity**: medium
- **Billing dimension**: function-duration
- **Traffic-independent**: no (cold-path findings get dropped)

**Description.** headers(), cookies(), and draftMode() are dynamic APIs. Reading them in a page/layout makes the entire segment dynamic — no ISR, no static generation, and a function invocation on every request.

**Fix.** Move the dynamic API call into a child Server Component that lives inside a Suspense boundary. The parent can stay static; only the leaf re-renders dynamically.

**Citations:**
- `https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config`
- `https://nextjs.org/docs/app/building-your-application/caching`

---

### `large-static-asset` — Large file in public/

- **Severity**: medium
- **Billing dimension**: bandwidth
- **Traffic-independent**: yes (cold-path findings survive the doctrine drop)

**Description.** Static assets in `public/` over 500 KB ship as-is from the CDN. Whether the cost is meaningful depends on traffic, but the candidate is binary — the file is either needed at that size or it can be optimized (compressed image, video transcode, or moved off the critical path).

**Fix.** Verify the asset is reachable on the customer-facing hot path. Then choose: (a) compress (convert PNG → AVIF/WebP; transcode MP4 to lower bitrate); (b) host externally (Vercel Blob, S3, or a media CDN with per-asset signed URLs); (c) lazy-load (defer to client-side fetch instead of bundling into initial HTML).

**Citations:**
- `https://vercel.com/docs/manage-cdn-usage`
- `https://vercel.com/docs/image-optimization`

---

### `max-age-without-s-maxage` — Cache-Control: max-age without s-maxage

- **Severity**: medium
- **Billing dimension**: edge-requests
- **Traffic-independent**: no (cold-path findings get dropped)

**Description.** max-age caches in the browser; s-maxage caches at the CDN. Without s-maxage, every uncached visitor request invokes the function. Adding s-maxage often cuts function invocations by 80%+ on read-heavy routes.

**Fix.** Add s-maxage to the Cache-Control header. Example: Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=86400. Pair with explicit cache-bust strategy if content can change.

**Citations:**
- `https://vercel.com/docs/caching/cdn-cache`
- `https://vercel.com/docs/caching/cache-control-headers`

---

### `middleware-broad-matcher` — Middleware matcher missing or too broad

- **Severity**: high
- **Billing dimension**: edge-requests
- **Traffic-independent**: yes (cold-path findings survive the doctrine drop)

**Description.** middleware.ts without a config.matcher (or matcher: ["/(.*)"]) runs on every request including _next/static, _next/image, favicon.ico, and image asset fetches. Edge-request cost scales accordingly.

**Fix.** Scope the matcher to actual application paths. Example: matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"]

**Citations:**
- `https://nextjs.org/docs/app/building-your-application/routing/middleware`

---

### `missing-cache-headers` — Cacheable route or fetch with no caching (Cache-Control absent or no-store)

- **Severity**: medium
- **Billing dimension**: edge-requests
- **Traffic-independent**: no (cold-path findings get dropped)

**Description.** Two antipatterns: (a) GET handlers without explicit Cache-Control headers serve uncached; (b) fetch() calls with cache:"no-store" or next:{revalidate:0} opt out of caching even on cacheable upstream data. For non-auth routes / fetches, both are leaving cache hits on the floor.

**Fix.** For GET handlers: return a Response with Cache-Control: public, s-maxage=<seconds>, stale-while-revalidate=<window>. For fetch(): drop cache:"no-store" (use { next: { revalidate: <seconds> } } in Next.js) so the response is cached by the framework + CDN.

**Citations:**
- `https://vercel.com/docs/caching/cdn-cache`
- `https://vercel.com/docs/caching/cache-control-headers`
- `https://nextjs.org/docs/app/building-your-application/caching`

---

### `prisma-include-tree-bloat` — Deep Prisma include tree (3+ levels)

- **Severity**: high
- **Billing dimension**: function-duration
- **Traffic-independent**: no (cold-path findings get dropped)

**Description.** Nested .include({ x: { include: { y: { include: { z: ... } } } } }) makes Prisma issue a single huge join that scales O(N*M*K). Function duration explodes, memory spikes, often causes timeouts.

**Fix.** Replace with explicit .findMany() calls or scoped .include() of only what the consumer reads. Consider Prisma.select() to project specific fields. For lists, batch with DataLoader patterns.

**Citations:**
- `vercel-react-best-practices:server-parallel-fetching`

---

### `region-pin-in-config` — Function region pinned in config

- **Severity**: low
- **Billing dimension**: function-duration
- **Traffic-independent**: yes (cold-path findings survive the doctrine drop)

**Description.** vercel.json `regions` or per-route `preferredRegion` is set. If the pinned region is far from the dominant user geo (or far from a data source) p95 TTFB suffers. This scanner provides the configured-region signal so the region-misconfig gate can recommend an audit.

**Fix.** Audit the pinned region against traffic geography (Speed Insights or Web Analytics by country) and data-source location. Consider multi-region if data lives in a fixed location and users are global; consider relocating if users are concentrated in one geography.

**Citations:**
- `https://vercel.com/docs/functions/configuring-functions/region`
- `https://vercel.com/docs/functions/configuring-functions/region`

---

### `source-maps-production` — Source maps enabled in production

- **Severity**: low
- **Billing dimension**: edge-requests
- **Traffic-independent**: yes (cold-path findings survive the doctrine drop)

**Description.** productionBrowserSourceMaps: true ships .map files in the production bundle, increasing transfer size 30-100% per visitor. Useful for error reporting via Sentry; not useful for users.

**Fix.** Keep source maps generation but exclude them from the public bundle. Upload to your error tracker via build-time CI step; do not serve them with the deployment.

**Citations:**
- `https://nextjs.org/docs/messages/improper-devtool`

---

### `sveltekit-prerender-missing` — SvelteKit page without explicit prerender / ISR config

- **Severity**: low
- **Billing dimension**: function-duration
- **Traffic-independent**: no (cold-path findings get dropped)

**Description.** SvelteKit page or +page.server.ts is missing an explicit `prerender`, `ssr`, or adapter `config.isr` declaration. Default is per-request function execution — investigate whether the route could be prerendered or ISR-cached.

**Fix.** If the page is static (no per-user / per-request data), add `export const prerender = true` in +page.ts or +page.server.ts. If the data refreshes on a schedule, prefer adapter-vercel's ISR option via `export const config = { isr: { expiration: 60 } }`.

**Citations:**
- `https://kit.svelte.dev/docs/page-options`
- `https://kit.svelte.dev/docs/adapter-vercel`
- `https://vercel.com/docs/incremental-static-regeneration`

---

### `turbo-force-bypass` — Turborepo cache bypass on a monorepo

- **Severity**: high
- **Billing dimension**: build
- **Traffic-independent**: yes (cold-path findings survive the doctrine drop)

**Description.** Turborepo's per-task cache can be bypassed by an explicit force flag, a `cache: false` config, or missing build-skip configuration. Every commit can rebuild unchanged work; Build Minutes climb with project count.

**Fix.** Remove `TURBO_FORCE=true` from build env/scripts unless intentional. Set `tasks.build.cache: true` in `turbo.json` (or remove the override), and include generated outputs in Turbo's cache contract. Prefer Vercel's skip-unaffected monorepo behavior when available; use `ignoreCommand` only when that setting cannot cover the project.

**Citations:**
- `https://vercel.com/docs/monorepos`
- `https://vercel.com/docs/builds`
- `https://turborepo.dev/docs/crafting-your-repository/caching`

---

### `unoptimized-image` — Image optimization gap (raw <img>, global flag, missing sizes, or SVG mis-routed)

- **Severity**: high
- **Billing dimension**: image-optimization
- **Traffic-independent**: no (cold-path findings get dropped)

**Description.** Four shapes of image-cost waste: raw <img> tags bypass the framework Image component; `images.unoptimized: true` disables Vercel image optimization globally; <Image fill> without `sizes` forces serving the largest source variant; <Image src=".svg"> without `unoptimized` routes vector data through the raster pipeline.

**Fix.** For raw <img>: switch to next/image, enhanced-img (SvelteKit), <Image /> (Astro), or NuxtImg. For global unoptimized:true: remove the flag unless the project is hosted outside Vercel. For fill without sizes: add `sizes="(max-width: 768px) 100vw, 50vw"` or whatever matches your layout. For SVG: add `unoptimized` so the raw SVG ships instead of rastering it.

**Citations:**
- `https://nextjs.org/docs/app/api-reference/components/image`
- `https://vercel.com/docs/image-optimization`

---

### `use-cache-date-stamp` — new Date() / Date.now() / Math.random() inside a 'use cache' file

- **Severity**: high
- **Billing dimension**: isr
- **Traffic-independent**: no (cold-path findings get dropped)

**Description.** `'use cache'` memoizes by argument identity AND prerender output. A timestamp baked into the cached output (`new Date().getFullYear()` in a footer, `Date.now()` in a payload field) forces a fresh ISR write on every regeneration even when the underlying data is unchanged. Random values have the same failure mode.

**Fix.** Replace module-scope `new Date()` with a build-time constant (`const buildYear = new Date().getFullYear()`) or move per-request timestamps into a client component inside `useEffect`. Do not pass dates as arguments to `'use cache'` functions — they invalidate the cache every call.

**Citations:**
- `https://nextjs.org/docs/app/api-reference/directives/use-cache`
- `https://nextjs.org/docs/app/api-reference/functions/cacheLife`

---
