Claude Skill

sota-javascript-typescript

State-of-the-art JavaScript and TypeScript engineering (2026) for both writing and auditing code. Covers strict TypeScript configuration and type design, language idioms and pitfalls, async patterns, Node.js backends, JS/TS-specific security (XSS, prototype pollution, supply chai

LLM Mart · 0 points · 8 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download martinholovsky-SOTA-skills-skills_sota-javascript-typescript-965222d.zip · 56 KB
Part of martinholovsky/sota-skills — 39 skills

Install

skills CLI npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-javascript-typescript
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
Git git clone https://github.com/martinholovsky/SOTA-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

SOTA JavaScript / TypeScript Engineering

Purpose

This skill encodes 2026 state-of-the-art for JS/TS so generated code is strict, secure, and fast by default — and so audits of existing code find the bug classes that actually bite: untyped boundaries, floating promises, XSS sinks, prototype pollution, supply-chain gaps, event-loop blocking, and leak-prone listeners. It has two operating modes; pick one explicitly at the start of a task.

Baseline assumptions (mid-2026): TypeScript ≥5.9 strict (6.0 = last JS-based compiler; 7.0 Go-native stable since July 2026, shipped as the regular typescript package — frameworks embedding the compiler API via Volar, e.g. Vue/Angular/Astro/Svelte, stay on 6.0 until a stable plugin API lands), ESM-first, Node LTS ≥22 (24 = active LTS; Node 26 ships Temporal by default), ES2024+ available, React 19.2-era with Server Components and React Compiler 1.0 where relevant, vitest 4 + flat-config ESLint (v9/v10).

BUILD mode (writing or modifying code)

  1. Read the relevant rules files first (index below) for the area you're touching. Don't generate from memory what a rules file specifies.
  2. Defaults unless the codebase dictates otherwise: strict tsconfig (rules/01), ESM, unknown over any, discriminated unions for state, zod/valibot parse at every untrusted boundary, ??/?. discipline, AbortController on cancellable ops, pino logging in services, Web APIs over deps.
  3. Match the host codebase for style, framework, and structure — but do not replicate its security bugs or any-sprawl into new code. New code meets the bar even in old repos.
  4. Boundary rule: every input from outside the type system (HTTP, env, JSON.parse, storage, postMessage, DB without typed client) is parsed with a schema before use. No as T on external data.
  5. Finish the job: new code compiles under tsc --noEmit, passes lint, and ships with behavior-level tests (rules/07). Handle the error path of every async call — no floating promises.
  6. When a requirement conflicts with a rule (e.g., legacy CJS, jest), follow the codebase and note the deviation; don't silently half-apply both.

AUDIT mode (reviewing existing code)

Scope first (frontend? Node service? library?), then read the matching rules files and run their audit checklists — each ends with grep/eslint hunt patterns. Validate findings: confirm attacker-controlled data actually reaches the sink, confirm the perf issue is on a hot path. No speculative findings.

Severity conventions:

  • CRITICAL — remotely exploitable now: untrusted input reaching eval/innerHTML/exec/SQL, auth bypass, secrets exfiltratable via XSS.
  • HIGH — exploitable with conditions, or guaranteed-corruption bug class: missing boundary validation, prototype-pollution-prone merge of request data, floating promises dropping errors, tokens in localStorage, unbounded request bodies, float money math, sync crypto blocking the loop.
  • MEDIUM — weakened posture or latent defect: missing strict tsconfig flags, no graceful shutdown, missing CSP/timeouts, || vs ?? on falsy-valid values, index keys on mutable lists, unbounded caches, missing supply-chain controls.
  • LOW — hygiene/debt: dead deps, hasOwnProperty, console.log in services, snapshot sprawl, missing memoization on profiled-hot paths.

Finding format:

[SEVERITY] Title (CWE-xxx if security)
File: path/to/file.ts:42
Issue: what is wrong, in one or two sentences
Evidence: the offending snippet
Impact: what an attacker/user/operator experiences
Fix: concrete change (code if short)

Order the report CRITICAL→LOW, deduplicate repeated patterns into one finding with a file list, and end with the top 3 systemic recommendations (e.g., "enable noUncheckedIndexedAccess", "adopt MSW", "add zod to route boundaries").

Rules index

File Read this when...
rules/01-typescript-config-and-types.md touching tsconfig; designing types/interfaces; seeing any/casts; modeling state; validating input shape; setting up a library or monorepo; deciding zod-vs-types questions
rules/02-language-idioms.md writing any JS/TS logic: equality, ??/?., array methods, immutability, Map/Set, in-band sentinels (absence encoded as -1/0/"") — -1 lies where NaN poisons, error classes and Result types, generators, dates (Temporal), money/number precision
rules/03-async-patterns.md anything with promises/async: combinator choice, floating promises, AbortController/timeouts, event-loop ordering, top-level await, workers, streams, async race conditions
rules/04-node-backend.md building/auditing Node services: runtime choice, dropping deps for built-ins, env config, HTTP hardening (timeouts/body limits), graceful shutdown, process error policy, pino
rules/05-security.md any security-relevant code or audit: XSS sinks, CSP, prototype pollution, npm supply chain, ReDoS, token storage/JWT, postMessage, child_process injection, SSRF
rules/06-performance.md bundle size, React re-renders/keys/RSC, virtualization, debounce/throttle, memory leaks, Node event-loop blocking, profiling before optimizing
rules/07-testing-and-tooling.md writing tests or setting up tooling: vitest, testing-library behavior testing, MSW, Playwright, ESLint flat + typescript-eslint strict, knip, publint/attw, CI gates. Test strategy — suite shape, TDD, doubles, test data, flake policy — lives in sota-testing; load it for any build that writes logic. This file owns JS/TS runner mechanics only.

For a full audit, read 01→07 in order; security-focused audits prioritize 05, 01, 03, 04.

Top 10 non-negotiables

  1. strict: true + noUncheckedIndexedAccess in every tsconfig; no any, no @ts-ignore — unknown + narrowing, @ts-expect-error with reason.
  2. Parse, don't cast, at boundaries: zod/valibot on every HTTP body/query, env var, JSON.parse, webhook, postMessage payload. Types inside, schemas at the edge.
  3. No floating promises: every promise awaited or explicitly .catch-handled; @typescript-eslint/no-floating-promises as error. allSettled for independent work; bounded concurrency.
  4. Discriminated unions for state, exhaustive switch with never default — no boolean-soup interfaces with optional data/error pairs.
  5. === always; ??/?. over ||/&& for null-handling; immutable updates (toSorted, structuredClone, spread) on shared data.
  6. Errors are Error subclasses with cause; never throw strings; never swallow with empty catch; Node policy = log fatally then exit on uncaught.
  7. XSS sinks are forbidden by default: no innerHTML/dangerouslySetInnerHTML with non-constant input unless DOMPurify-sanitized at render; no eval family ever; CSP on HTML responses.
  8. No secrets in localStorage; no shell interpolation: httpOnly cookies for tokens; execFile/spawn array-args, never exec with template strings; parameterized SQL.
  9. Supply chain controlled: committed lockfile + npm ci, install scripts disabled/allowlisted, update cooldown, minimal deps — prefer platform built-ins (fetch, node:test, crypto.randomUUID).
  10. AbortController + timeouts on all I/O; never block the event loop (>50ms CPU → worker; no *Sync in request paths); listeners and intervals always cleaned up.
Files (sota-skills)
  • rules
    • 01-typescript-config-and-types.md 15.1 KB
      # TypeScript Configuration & Type Craft
      
      ## tsconfig: strict everything, no exceptions
      
      New projects start from this baseline. Existing projects migrate flag-by-flag; never ship with `strict: false`.
      
      ```jsonc
      {
        "compilerOptions": {
          // Strictness — all non-negotiable
          "strict": true,
          "noUncheckedIndexedAccess": true,      // arr[i] is T | undefined — catches the #1 runtime crash class
          "exactOptionalPropertyTypes": true,     // { x?: T } ≠ { x: T | undefined }
          "noImplicitOverride": true,
          "noFallthroughCasesInSwitch": true,
          "noPropertyAccessFromIndexSignature": true,
          "useUnknownInCatchVariables": true,     // implied by strict, listed for visibility
      
          // Module hygiene — ESM-first
          "module": "NodeNext",                   // or "ESNext" + "moduleResolution": "Bundler" for bundled apps
          "moduleResolution": "NodeNext",
          "verbatimModuleSyntax": true,           // forces `import type`; makes transpile-only tools (esbuild, swc) safe
          "isolatedModules": true,
      
          // Output / interop
          "target": "ES2024",
          "lib": ["ES2024"],                      // add "DOM", "DOM.Iterable" only for browser code
          "esModuleInterop": true,
          "skipLibCheck": true,                   // pragmatic: don't pay for broken third-party d.ts
          "forceConsistentCasingInFileNames": true,
          "declaration": true,                    // libraries only
          "sourceMap": true
        }
      }
      ```
      
      Rationale for the two flags most teams skip:
      - `noUncheckedIndexedAccess`: without it, `users[0].name` compiles and crashes on empty arrays. With it, you're forced to narrow: `const u = users[0]; if (!u) return;`. Use `.at(0)` which is honest (`T | undefined`) even without the flag.
      - `exactOptionalPropertyTypes`: distinguishes "absent" from "explicitly undefined". Critical for `JSON.stringify`, spread-merging config, and exactness of API payloads. Fix violations by deleting keys, not assigning `undefined`.
      
      `verbatimModuleSyntax` replaces deprecated `importsNotUsedAsValues`/`preserveValueImports`. It makes every file independently transpilable — required for esbuild/swc/Bun, and it documents intent: types via `import type`, values via `import`.
      
      TypeScript 6.0 (March 2026) is the last release on the JavaScript codebase; TypeScript 7.0 (the Go-native compiler, 8–12× faster builds, stable since July 2026) now ships as the regular `typescript` package. 6.0 flips defaults to `strict: true`, `module: "esnext"`, `target: "es2025"` and deprecates `baseUrl`, `moduleResolution: "node"`/`"classic"`, `outFile`, `target: "es5"`, and the non-strict interop flags — 7.0 makes them hard errors. The explicit config above stays valid under 6.0/7.0; migrate via 6.0 (treat its deprecation warnings as must-fix), then mind 7.0's changed defaults: `types` defaults to `[]` (list needed `@types` explicitly) and `rootDir` defaults to `./` (set it when tsconfig sits above `src`). Frameworks embedding the compiler API via Volar (Vue, Angular, Astro, Svelte, MDX) stay on 6.0 until 7.x exposes a stable programmatic API.
      
      ## No `any`. Use `unknown` + narrowing
      
      `any` disables the compiler transitively — it infects everything it touches. `unknown` is the type-safe top type: you must narrow before use.
      
      ```ts
      // BAD — any leaks; typo compiles, crashes at runtime
      function handle(e: any) { console.log(e.mesage); }
      
      // GOOD — unknown forces narrowing
      function handle(e: unknown) {
        if (e instanceof Error) console.log(e.message);
        else console.log(String(e));
      }
      ```
      
      - Catch clauses are `unknown` under strict mode. Never `catch (e: any)`.
      - `as any` in tests is still a bug factory; prefer typed builders/factories or `satisfies`.
      - Escape hatch hierarchy (best→worst): proper type > generic > `unknown` + guard > `as T` with a comment why > `// @ts-expect-error` with reason > `any` (forbidden; lint it: `@typescript-eslint/no-explicit-any: error`).
      - `@ts-expect-error` over `@ts-ignore` always — it errors when the suppression becomes stale.
      
      Narrowing toolkit: `typeof`, `instanceof`, `in`, `Array.isArray`, discriminant property checks, user-defined guards (`x is T`), assertion functions (`asserts x is T`). Prefer discriminant checks over custom guards — guards are unchecked promises; a wrong guard body is a silent `as`.
      
      ## Discriminated unions are the workhorse
      
      Model states as a closed union with a literal discriminant. This makes illegal states unrepresentable and gives exhaustive switches for free.
      
      ```ts
      // BAD — boolean soup; 4 fields allow 16 shapes, ~3 are valid
      interface State { loading: boolean; data?: User[]; error?: Error; }
      
      // GOOD — exactly the valid states exist
      type State =
        | { status: 'idle' }
        | { status: 'loading' }
        | { status: 'success'; data: User[] }
        | { status: 'error'; error: Error };
      
      function render(s: State) {
        switch (s.status) {
          case 'idle': return null;
          case 'loading': return spinner();
          case 'success': return list(s.data);   // data narrowed — no `!`
          case 'error': return alert(s.error);
          default: { const _exhaustive: never = s; throw new Error('unreachable'); }
        }
      }
      ```
      
      The `never` default makes adding a variant a compile error at every consumer — this is the whole point. Also enable `@typescript-eslint/switch-exhaustiveness-check`.
      
      Use unions for: API responses (success/error), form states, events (`{ type: 'click', ... } | { type: 'keydown', ... }`), Result types. If you find yourself writing `field?: T` pairs that are "both or neither", that's a union begging to exist.
      
      ## Branded types for IDs and units
      
      Structural typing means `UserId` and `OrderId` as plain `string` are interchangeable — a swapped argument compiles. Brand them:
      
      ```ts
      declare const brand: unique symbol;
      type Brand<T, B extends string> = T & { readonly [brand]: B };
      
      type UserId = Brand<string, 'UserId'>;
      type OrderId = Brand<string, 'OrderId'>;
      type Cents = Brand<number, 'Cents'>;
      
      const UserId = (s: string): UserId => s as UserId;  // single blessed constructor
      
      function getOrders(userId: UserId): Order[] { /* ... */ }
      getOrders(orderId);            // compile error — the bug class is dead
      ```
      
      Brand at the validation boundary (zod: `z.string().uuid().brand<'UserId'>()`). Brand money, durations (ms vs s), and anything where unit confusion has bitten anyone ever. Zero runtime cost.
      
      ## `satisfies` and `as const`
      
      `satisfies` validates against a type while preserving the narrower inferred type. `: Type` annotation widens; `as Type` lies.
      
      ```ts
      // BAD — annotation widens; config.port is string | number
      const config: Record<string, string | number> = { port: 3000, host: 'localhost' };
      
      // GOOD — checked AND port stays number
      const config = { port: 3000, host: 'localhost' } satisfies Record<string, string | number>;
      
      // as const for literal preservation + readonly
      const ROUTES = ['/home', '/about', '/admin'] as const;
      type Route = (typeof ROUTES)[number];   // '/home' | '/about' | '/admin'
      ```
      
      Pattern: derive types from values (`as const` + `typeof` + indexed access), not values from types. One source of truth.
      
      `as` casts: legitimate only at (1) trusted boundaries already validated at runtime, (2) `as const`, (3) widening `as unknown as T` quarantined in one adapter file with a comment. `as` in business logic is a finding.
      
      ## Validate at the boundary, trust the types inside
      
      TypeScript types are erased — they verify nothing at runtime. Every untrusted input (HTTP body, query params, env vars, JSON.parse, localStorage, webhooks, LLM output, DB rows from untyped clients) must be parsed, not cast.
      
      ```ts
      import { z } from 'zod';
      
      const CreateUser = z.object({
        email: z.string().email(),
        age: z.number().int().min(0).max(150),
        role: z.enum(['user', 'admin']).default('user'),
      });
      type CreateUser = z.infer<typeof CreateUser>;   // type derived from schema — one source of truth
      
      // BAD — a lie with extra steps
      const user = (await req.json()) as CreateUser;
      
      // GOOD — parse, don't validate-and-cast
      const result = CreateUser.safeParse(await req.json());
      if (!result.success) return badRequest(result.error.flatten());
      const user = result.data;   // genuinely CreateUser from here on
      ```
      
      - zod v4 / valibot (smaller bundles, tree-shakeable — prefer for frontend) / ArkType are all fine; pick one per repo.
      - Parse env once at startup into a typed, frozen config object; crash fast on missing vars (see rules/04).
      - Inside the boundary, do NOT re-validate everywhere — that's noise. Types carry the proof.
      - `JSON.parse` returns `any`. Wrap it: `const parseJson = (s: string): unknown => JSON.parse(s);`
      
      ## Utility types: use the built-ins, derive don't duplicate
      
      `Partial`, `Required`, `Readonly`, `Pick`, `Omit`, `Record`, `Exclude`, `Extract`, `NonNullable`, `Parameters`, `ReturnType`, `Awaited`. Derive variants from one canonical type:
      
      ```ts
      interface User { id: UserId; email: string; createdAt: Date; passwordHash: string; }
      type PublicUser = Omit<User, 'passwordHash'>;
      type UserPatch = Partial<Pick<User, 'email'>>;
      ```
      
      Caveats:
      - `Omit` doesn't distribute over unions. Use a distributive helper when omitting from a union: `type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;`
      - Prefer `Readonly<T>`/`readonly T[]` on function parameters you don't mutate — it documents and enforces.
      - `ReturnType<typeof fn>` couples consumers to implementation; fine internally, avoid in public API.
      
      ## Enums: don't. Use unions or const objects
      
      `enum` is non-erasable syntax (generates runtime code, breaks single-file transpilers — TS 5.8's `erasableSyntaxOnly` flag bans it outright), numeric enums are unsound (any number assigns), and const enums break under isolatedModules.
      
      ```ts
      // BAD
      enum Role { User, Admin }                 // Role.User === 0; role = 42 compiles
      
      // GOOD — string literal union (zero runtime, narrows perfectly)
      type Role = 'user' | 'admin';
      
      // GOOD — const object when you need runtime iteration/values
      const Role = { User: 'user', Admin: 'admin' } as const;
      type Role = (typeof Role)[keyof typeof Role];
      Object.values(Role);                      // runtime list for zod enums, dropdowns
      ```
      
      Same reasoning bans `namespace` and parameter properties in new code: prefer plain modules and explicit field assignment. Enable `erasableSyntaxOnly` where the toolchain is TS ≥5.8 — it guarantees the whole codebase is type-strippable (Node's native TS execution, esbuild, swc all benefit).
      
      ## Functions: generics, overloads, and shape
      
      - Generic only when a type relationship must be preserved (`function first<T>(xs: readonly T[]): T | undefined`). A type parameter used once in the signature is usually noise — take `unknown` or the concrete type.
      - Constrain at the parameter (`<T extends HasId>`), return the narrow type; avoid `extends object` (allows arrays/functions — usually you mean `Record<string, unknown>`).
      - Prefer union parameters over overloads; overloads only when return type depends on argument type in ways unions can't express. Overload implementations are unchecked against each signature — keep them trivial.
      - Options-object for ≥3 params or any boolean (`createUser(email, { sendWelcome: true })` — call sites self-document; booleans positionally are unreadable).
      - Return types: annotate exported/public functions explicitly (inference drift across refactors changes your API silently; `explicit-module-boundary-types` lint); let locals infer.
      - Template literal types shine for constrained string APIs: `type EventName = \`on${Capitalize<string>}\`;`, route params extraction, CSS unit types — stop before you've written a parser in the type system (see next section).
      
      ## When type gymnastics hurt
      
      Types serve the code. Stop when:
      - A conditional/mapped type takes longer to understand than the duplication it removes. Two similar interfaces are often cheaper than one clever generic.
      - Inference errors surface 5 layers from the cause. Recursive conditional types produce unreadable diagnostics for teammates.
      - Compile time degrades (run `tsc --extendedDiagnostics`; deep template-literal and recursive types are the usual culprits).
      - You're encoding business rules better checked at runtime (e.g., "max 10 items" belongs in zod, not in a tuple-length type).
      
      Heuristics: max ~2 levels of nested conditional types in app code; name intermediate types; if a type needs a comment explaining how it works (not what it means), simplify it. Libraries can spend more complexity than apps — their users see only the inferred results.
      
      ## ESM-first and monorepo project references
      
      - New code is ESM: `"type": "module"` in package.json, `import`/`export` only. No `require`, no `module.exports`. CJS interop via default-import of CJS packages works under `esModuleInterop`.
      - `module: "NodeNext"` requires explicit `.js` extensions on relative imports in Node libraries (`import { x } from './util.js'`). Bundled apps using `moduleResolution: "Bundler"` may omit them.
      - Library `package.json`: use `exports` map with `types` condition first; ship `.d.ts` next to `.js`. Verify with `publint` and `arethetypeswrong` (see rules/07).
      
      Monorepos: TypeScript project references give incremental, dependency-ordered builds:
      
      ```jsonc
      // packages/api/tsconfig.json
      {
        "extends": "../../tsconfig.base.json",
        "compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" },
        "references": [{ "path": "../shared" }]
      }
      ```
      
      - Root: `tsc -b` (build mode). Each package: `composite: true` + `declaration: true`.
      - Import via workspace package names (`@app/shared`), never `../../shared/src/...` cross-package relative paths.
      - Pair with pnpm workspaces + turborepo/nx for task caching. Set `declarationMap: true` so go-to-definition lands in source, not d.ts.
      
      ## Audit checklist
      
      - [ ] `tsconfig.json`: `strict: true` present; `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `isolatedModules` enabled. Any of these missing in a 2026 codebase is a finding (HIGH for missing `strict`).
      - [ ] `grep -rn ": any\|as any\|<any>\|any\[\]" --include="*.ts" --include="*.tsx" src/` — every hit needs justification; `as any` in production code is HIGH.
      - [ ] `grep -rn "@ts-ignore\|@ts-nocheck" src/` — should be `@ts-expect-error` with a reason comment; `@ts-nocheck` is HIGH.
      - [ ] `grep -rn "as [A-Z]" --include="*.ts" src/ | grep -v "as const\|as unknown"` — casts in business logic; check each masks no missing validation.
      - [ ] `grep -rn "JSON.parse\|req.body\|req.query\|process.env" src/` — confirm each flows through a schema parse before typed use; raw `as T` on external data is HIGH.
      - [ ] `grep -rn "loading: boolean" src/` plus adjacent optional `data`/`error` fields — boolean-soup state, recommend discriminated union (MEDIUM).
      - [ ] IDs typed as bare `string` passed across ≥2 entity types — recommend branding (LOW/MEDIUM by blast radius).
      - [ ] ESLint has `typescript-eslint` strict-type-checked config; `no-explicit-any`, `switch-exhaustiveness-check`, `no-unsafe-*` rules enabled.
      - [ ] Monorepo: cross-package relative imports (`grep -rn "from '\.\./\.\./.*/src/"`) — bypass project references (MEDIUM).
      - [ ] `grep -rn "^export enum\|^enum \|const enum" src/` — migrate to unions/const objects (LOW; const enum under isolatedModules is MEDIUM).
      - [ ] Exported functions without explicit return types in library/public API (`explicit-module-boundary-types` lint) — LOW.
      - [ ] Libraries: `exports` map present, `publint` + `attw` pass.
      
    • 02-language-idioms.md 17.2 KB
      # Language Idioms & Pitfalls
      
      ## Equality and coercion
      
      - Always `===`/`!==`. `==` coercion rules are unmemorizable (`[] == false`, `'' == 0`, `null == undefined` all true). Single allowed exception: `x == null` to test null-or-undefined at once — but `x === null || x === undefined` or `x ?? fallback` is clearer; just ban `==` entirely (`eslint eqeqeq: ["error", "always"]`).
      - `Object.is` only for `NaN`/`-0` distinction. `NaN === NaN` is false; use `Number.isNaN(x)` — never the global `isNaN`, which coerces (`isNaN('foo')` is true).
      - `Number.isInteger`, `Number.isFinite` over global counterparts for the same reason.
      
      ## Nullish discipline: `??` and `?.`
      
      `||` treats `0`, `''`, `false` as missing. `??` only treats `null`/`undefined` as missing.
      
      ```ts
      // BAD — port 0 and empty prefix silently replaced
      const port = config.port || 3000;
      const prefix = config.prefix || '/api';
      
      // GOOD
      const port = config.port ?? 3000;
      const retries = opts.retries ?? 3;        // retries: 0 respected
      el.count ??= 0;                            // nullish assignment
      ```
      
      Optional chaining rules:
      - `?.` is for genuinely-optional data, not for silencing the compiler. A long chain `a?.b?.c?.d` usually means the type is wrong or validation was skipped upstream — fix the source.
      - `x?.()` for optional callbacks; `arr?.[i]` for optional indexing.
      - Don't combine `?.` with non-null assertion `!` — pick one truth. `!` is banned in app code except immediately after an explicit check the compiler can't see (document why); prefer restructuring so narrowing works.
      - Remember `a?.b.c` short-circuits the whole chain when `a` is nullish — `.c` is safe; but `(a?.b).c` is not.
      
      ## Array method selection
      
      Pick the method that states intent; reviewers read methods faster than loop bodies.
      
      | Need | Use | Not |
      |---|---|---|
      | transform each | `map` | `forEach` + push |
      | keep some | `filter` | manual loop |
      | first match | `find` / `findIndex` / `findLast` | `filter(...)[0]` |
      | any/all match | `some` / `every` | `filter(...).length > 0` |
      | reduce to one value | `reduce` (sparingly) | — |
      | flatten + map | `flatMap` | `map(...).flat()` |
      | membership | `includes` | `indexOf !== -1` |
      | group | `Object.groupBy` / `Map.groupBy` (ES2024) | reduce boilerplate |
      | index from end | `at(-1)` | `arr[arr.length - 1]` |
      
      - `forEach` only for pure side effects; it ignores return values and cannot `await` correctly (`forEach(async ...)` fires-and-forgets every iteration — classic bug; use `for...of` with `await`, or `Promise.all(arr.map(...))` for parallel).
      - `reduce` building objects/arrays with spread per iteration is O(n²) — use a mutable accumulator inside the reduce or a plain loop.
      - Early-exit needs: `some`/`every`/`find` short-circuit; `map`/`filter` don't — use `for...of` when you must break out of a transform.
      - Don't chain `filter().map()` over hot million-element arrays; one `for...of` or `flatMap` pass is fine. Below that scale, readability wins.
      
      ## In-band sentinels: `-1`, and why `NaN` is the better-behaved one
      
      `indexOf`, `lastIndexOf` and `findIndex` all return `-1` when not found (verified,
      Node 24). The idiom is fine where you test it immediately — `.includes()` /
      `.some()` say what you mean — and becomes the class in `sota-architecture` rules/02 §8a
      the moment the `-1` is stored, passed, or compared later.
      
      Two sentinels with **opposite** failure behaviour, both verified:
      
      | | `> 20` | `< 20` | consequence |
      |---|---|---|---|
      | `-1` | `false` | `true` | **lies**: wins one ordering, loses the other |
      | `NaN` | `false` | `false` | **poisons**: every comparison is false, incl. `NaN === NaN` |
      
      `parseInt("x")` → `NaN` is therefore the *safer* of the two: it cannot silently win
      a comparison, and `Number.isNaN` is an unambiguous test. `-1` cannot be tested
      without knowing the field's domain. Neither is as good as `null`/`undefined` with
      `strictNullChecks` and `??` (see *Nullish discipline* above) — note `-1 ?? fallback`
      is `-1`, so `??` does **not** rescue a sentinel; only `null`/`undefined` trigger it.
      
      - TS: type the absent case (`number | null`), never `number` with a documented
        magic value. A `-1` in a return type is invisible to every checker.
      - Audit: `grep -rnE 'return -1|=== -1|!== -1' --include='*.ts' --include='*.js' src/`
        — the `=== -1` hits are usually correct (immediate tests); the `return -1` hits are
        the producers, and a stored `-1` is where it goes wrong.
      
      ## Immutability patterns
      
      Mutating shared data causes spooky action at a distance and breaks React/state-library change detection.
      
      ```ts
      // BAD — sort/reverse/splice mutate in place
      const sorted = users.sort((a, b) => a.age - b.age);   // also reordered `users`!
      
      // GOOD — ES2023 change-by-copy methods
      const sorted = users.toSorted((a, b) => a.age - b.age);
      const reversed = items.toReversed();
      const without = items.toSpliced(i, 1);
      const updated = items.with(i, newItem);
      ```
      
      - Mutators to flag on shared/parameter arrays: `sort`, `reverse`, `splice`, `push/pop/shift/unshift`, `fill`, `copyWithin`. Local arrays you just created may be mutated freely — purity at the boundary, pragmatism inside.
      - Deep copy: `structuredClone(obj)` — handles Dates, Maps, Sets, cycles, typed arrays. Never `JSON.parse(JSON.stringify(x))` (drops `undefined`, functions, Dates become strings, throws on cycles). Note structuredClone drops functions and prototypes — data only.
      - Shallow update idiom: `{ ...obj, field: v }` / `[...arr, item]` — shallow is fine when nested values are themselves replaced, not mutated.
      - Declare `readonly` arrays/properties in signatures; `as const` for fixed tables. `Object.freeze` is shallow and dev-only value — types are the real enforcement.
      - `let` is a smell outside loops/accumulators; `const` everywhere (`prefer-const` lint).
      
      ## Map/Set over object-as-map
      
      Objects as dictionaries inherit `Object.prototype` (`'toString' in obj` is true!), stringify all keys, and are the prototype-pollution sink.
      
      ```ts
      // BAD
      const cache: Record<string, User> = {};
      if (cache[name]) ...        // breaks for name = "constructor"
      
      // GOOD
      const cache = new Map<string, User>();
      cache.set(name, user);
      cache.get(name);
      ```
      
      - `Map`: arbitrary key types, `.size`, guaranteed insertion order, faster frequent add/delete, no prototype hazards.
      - `Set` for membership: `seen.has(x)` is O(1) vs `arr.includes(x)` O(n). Dedupe: `[...new Set(arr)]`.
      - If an object truly must be a dictionary (JSON shape), create it via `Object.create(null)` or always guard with `Object.hasOwn(obj, key)` (ES2022 — replaces `obj.hasOwnProperty`).
      - `WeakMap`/`WeakSet` to associate data with objects without preventing GC (e.g., DOM node metadata, memoization keyed by object).
      - `Map.prototype.getOrInsert(key, default)` / `getOrInsertComputed(key, fn)` (V8 14.6: Node 26, Chrome 146+) replace the check-then-set dance for cache/grouping maps — use where your runtime floor allows.
      - `Record<string, T>` indexing under `noUncheckedIndexedAccess` correctly yields `T | undefined` — Map's `.get` was always honest about this.
      
      ## Error handling
      
      Never throw strings or plain objects — they lose stack traces and break `instanceof` routing.
      
      ```ts
      // BAD
      throw 'user not found';
      throw { code: 404 };
      catch (e) { console.log(e); throw new Error('failed: ' + e); }   // stack lost
      
      // GOOD — subclass + cause chain
      class NotFoundError extends Error {
        constructor(public readonly resource: string, public readonly id: string, opts?: ErrorOptions) {
          super(`${resource} ${id} not found`, opts);
          this.name = 'NotFoundError';
        }
      }
      
      try {
        await db.query(sql);
      } catch (e) {
        throw new NotFoundError('user', id, { cause: e });   // ES2022 cause preserves the chain
      }
      ```
      
      Rules:
      - `catch (e)` is `unknown` — narrow with `instanceof` before reading `.message`. Helper for the rest: `const toError = (e: unknown): Error => e instanceof Error ? e : new Error(String(e), { cause: e });`
      - Set `this.name` in subclasses; route on `instanceof` or a `code` field, never on message text.
      - Always pass `{ cause: e }` when wrapping — loggers (pino) serialize the chain.
      - Never swallow: empty `catch {}` is a finding unless commented with why. Catch only where you can handle or add context; otherwise let it propagate.
      - `finally` for cleanup; or ES2026 explicit resource management: `using conn = await pool.acquire()` with `[Symbol.asyncDispose]` (use `await using` for async disposal) — adopt where the runtime/tsconfig supports it.
      
      Result-style for expected failures: exceptions for bugs/infra, values for domain outcomes the caller must handle.
      
      ```ts
      type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
      
      async function parsePrice(input: string): Promise<Result<Cents, 'invalid' | 'negative'>> { /* ... */ }
      
      const r = await parsePrice(raw);
      if (!r.ok) return showError(r.error);   // compiler forces the check
      use(r.value);
      ```
      
      Use Result (hand-rolled discriminated union, or neverthrow if the team wants combinators) for validation, parsing, business-rule failures. Don't Result-ify everything — infra errors (DB down) should still throw. ES2025 `try`-expression proposals aside, today the union is the idiom. `safeParse` from zod is exactly this pattern.
      
      ## Iterators and generators
      
      - Generators for lazy/infinite/paginated sequences — they avoid materializing intermediate arrays:
      
      ```ts
      async function* paginate(url: string): AsyncGenerator<Item> {
        let next: string | null = url;
        while (next) {
          const page = await fetchPage(next);
          yield* page.items;
          next = page.nextUrl;
        }
      }
      for await (const item of paginate(api)) { if (matches(item)) break; }  // stops fetching early
      ```
      
      - ES2025 iterator helpers: `Iterator.from(it).filter(f).map(g).take(10).toArray()` — lazy chaining without arrays.
      - Make domain collections iterable via `[Symbol.iterator]` rather than exposing internal arrays.
      - Caveat: generators are single-pass; spreading one consumes it. Don't iterate twice.
      
      ## Proxy caution
      
      `Proxy` is for frameworks (Vue reactivity, immer), not application code. Costs: every property access pays a trap-call penalty; identity breaks (`proxy !== target`); `this`-binding bugs with private fields and built-ins (Map/Date methods throw through naive proxies); devtools/debugging opacity. If you reach for Proxy in app code, the answer is almost always an explicit function, a class, or a Map. `Reflect.*` belongs inside proxy handlers, rarely elsewhere.
      
      ## Dates: Temporal, and surviving without it
      
      `Date` is mutable, months are 0-indexed, parsing is implementation-defined, and it has no timezone besides local/UTC. Temporal (Stage 4 March 2026, part of ES2026; shipped in Chrome/Edge 144+, Firefox 139+, and enabled by default in Node 26) fixes all of it — immutable, explicit types:
      
      ```ts
      // GOOD — Temporal (Safari still hasn't shipped it — use `temporal-polyfill` for web targets)
      const meeting = Temporal.ZonedDateTime.from('2026-03-08T09:00[America/New_York]');
      const later = meeting.add({ hours: 2 });                       // DST-safe
      const dur = end.since(start);                                  // Temporal.Duration
      const today = Temporal.Now.plainDateISO('Europe/Prague');
      ```
      
      Type selection: `Instant` for timestamps, `PlainDate` for calendar dates (birthdays — no timezone!), `PlainTime`, `ZonedDateTime` for wall-clock + zone, `Duration` for spans. Choosing the right type eliminates the bug class.
      
      Until Temporal is available everywhere you ship (Node ≥26 backends: it is; browser code: polyfill until Safari ships): store/transmit UTC ISO-8601 strings or epoch ms; convert at display; use date-fns (tree-shakeable) over dayjs/moment (moment is dead — flag it). Never do arithmetic by adding `86400000` — DST days are 23/25h.
      
      ## Number precision and money
      
      - All JS numbers are float64: `0.1 + 0.2 !== 0.3`; integers exact only to `Number.MAX_SAFE_INTEGER` (2^53−1). 64-bit DB IDs and Twitter snowflakes silently corrupt as numbers — keep them strings.
      - Money: integer minor units (cents) in a branded type, never floats.
      
      ```ts
      type Cents = Brand<number, 'Cents'>;
      const total = (items: readonly Cents[]) => items.reduce((a, b) => (a + b) as Cents, 0 as Cents);
      const display = (c: Cents) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(c / 100);
      ```
      
      - Division/percentages: decide the rounding rule explicitly (`Math.round` half-away-from-zero vs banker's), document it, test it. Allocation (splitting $10 three ways) must distribute the remainder, not round each share.
      - `BigInt` for >2^53 integers (crypto, snowflakes, wei). Don't mix with `number` (`1n + 1` throws); `JSON.stringify` throws on BigInt — serialize as string.
      - High-precision decimals (rates, FX): a decimal library (`decimal.js`/`big.js`) or do the math in the database. The TC39 Decimal proposal isn't shipped.
      - `parseFloat`/`parseInt` accept garbage prefixes (`parseInt('12px')` → 12). Prefer `Number(str)` + `Number.isFinite` check, or zod `z.coerce.number()`. Always pass radix if you do use `parseInt(s, 10)`.
      
      ## Functions and modules over classes; classes where they earn it
      
      Default unit of design: pure functions + plain data (typed objects), composed in modules. Classes earn their place for: stateful long-lived things with invariants (connection pools, caches), Error subclasses, when a framework expects them. Avoid:
      
      - Classes as namespaces (all-static members) — use a module.
      - Single-implementation interfaces + DI-container ceremony in app code — pass dependencies as function/constructor parameters directly; introduce the interface when the second implementation (or the test fake) actually exists.
      - Inheritance for code reuse — compose; `extends` only for genuine is-a with stable base (Error, framework bases). Deep hierarchies in JS are refactor glue traps.
      - Getters with side effects or surprise allocation; getters that throw.
      
      ```ts
      // BAD — class-as-namespace + hidden temporal coupling
      class UserService { static db: Db; static async get(id: string) { return this.db.find(id); } }
      
      // GOOD — explicit deps, trivially testable
      export const makeUserService = (db: Db) => ({
        get: (id: UserId) => db.find(id),
        // ...
      });
      export type UserService = ReturnType<typeof makeUserService>;
      ```
      
      Module hygiene: no side effects at import time (registrations, connections, reading env) outside the composition root — importing a module should be safe and free. Side-effectful imports break tree-shaking, tests, and tooling.
      
      ## Strings and Unicode
      
      - `str.length` counts UTF-16 code units, not characters: `'👨‍👩‍👧'.length === 8`. Iterate by code point (`[...str]`, `for...of`) for character-ish ops; grapheme-correct counting/truncation needs `Intl.Segmenter`:
      
      ```ts
      const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
      const truncate = (s: string, n: number) => [...seg.segment(s)].slice(0, n).map(x => x.segment).join('');
      ```
      
      - Normalize before comparing user-entered text: `a.normalize('NFC') === b.normalize('NFC')` ('é' has two encodings).
      - Locale-aware comparison/sorting: `Intl.Collator`/`localeCompare`, never `<` on strings for human-facing sort. All formatting (numbers, dates, lists, plurals) via `Intl.*` — never hand-rolled `${day}/${month}` strings.
      - `replaceAll` over `replace(/g/)` for literal replacement (no regex-escaping bugs). When building a RegExp from user input is unavoidable, escape it (`RegExp.escape` (ES2025) or the well-known escape helper) — see rules/05 ReDoS.
      - Multi-line template literals respect indentation — use `dedent` or keep them flush-left; don't ship accidental leading whitespace in SQL/emails.
      
      ## Audit checklist
      
      - [ ] In-band sentinels: `grep -rnE 'return -1' --include='*.ts' --include='*.js' src/` — a
            `-1` that is **stored or passed** rather than tested on the next line. Remember `??`
            does not rescue it (`-1 ?? x` is `-1`); only `null`/`undefined` trigger it.
      
      - [ ] `grep -rn "[^=!]==[^=]\|!=[^=]" --include="*.ts" src/` — loose equality (MEDIUM; eqeqeq lint).
      - [ ] `grep -rn "|| 0\||| ''\||| \[\]\||| {}" src/` and `\b(port|count|index|limit|offset|retries)\s*=.*||` — `||` where `??` is meant (MEDIUM, HIGH if money/ports).
      - [ ] `grep -rn "forEach(async" src/` — fire-and-forget async iteration (HIGH).
      - [ ] `grep -rn "\.sort(\|\.reverse(\|\.splice(" src/` — verify each operates on a locally-owned array, else `toSorted`/`toReversed`/`toSpliced` (MEDIUM).
      - [ ] `grep -rn "JSON.parse(JSON.stringify" src/` — replace with `structuredClone` (LOW/MEDIUM).
      - [ ] `grep -rn "throw ['\"\`]\|throw {" src/` — thrown non-Errors (HIGH).
      - [ ] `grep -rn "catch ([a-z]*)\s*{\s*}" src/` and `catch.*{\s*$` followed by `}` — swallowed errors (HIGH).
      - [ ] `grep -rn "new Error(.*+\|new Error(\`" src/` — wrapping without `{ cause }` (LOW).
      - [ ] `grep -rn "hasOwnProperty" src/` — use `Object.hasOwn` (LOW).
      - [ ] `grep -rn "price\|amount\|total\|balance" --include="*.ts" src/ | grep -i "float\|\* 0\.\|/ 100\|toFixed"` — float money math (HIGH).
      - [ ] `grep -rn "from 'moment'\|require('moment')" src/` — dead library; migrate (MEDIUM). `new Date(` arithmetic with `86400000`/`3600000` constants — DST bugs (MEDIUM).
      - [ ] `grep -rn "!" --include="*.tsx" -l src/` then targeted `grep -rn "\w!\.\|\w!;" src/` — non-null assertions; each needs justification (MEDIUM in app code).
      - [ ] ESLint: `eqeqeq`, `prefer-const`, `no-param-reassign`, `@typescript-eslint/no-floating-promises`, `no-non-null-assertion` configured.
      
    • 03-async-patterns.md 14.7 KB
      # Async JavaScript
      
      ## Promise combinators: pick deliberately
      
      | Combinator | Resolves when | Rejects when | Use for |
      |---|---|---|---|
      | `Promise.all` | all fulfill | **first rejection** (others keep running, results dropped) | interdependent work — if one fails, the batch is useless |
      | `Promise.allSettled` | always (array of `{status, value/reason}`) | never | independent work — you want every outcome (batch sends, multi-source fetch) |
      | `Promise.race` | first settle (fulfil OR reject) | first rejection | timeouts (prefer `AbortSignal.timeout`), first-response-wins |
      | `Promise.any` | first fulfillment | all reject (`AggregateError`) | redundant sources/mirrors — first success wins |
      
      ```ts
      // BAD — one failed notification aborts reporting on all the rest
      const results = await Promise.all(users.map(notify));
      
      // GOOD — independent ops: collect all outcomes, then handle failures
      const results = await Promise.allSettled(users.map(notify));
      const failed = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
      if (failed.length) logger.warn({ count: failed.length }, 'notifications failed');
      ```
      
      - `Promise.all` losers are NOT cancelled — pass a shared `AbortSignal` and abort it in a catch if the others' work matters.
      - Sequential-vs-parallel: `await` in a loop is sequential (sometimes correct — rate limits, ordering); `Promise.all(arr.map(f))` is parallel. Choose explicitly; accidental sequential awaits are a top latency bug. For bounded parallelism over large sets use a pool (`p-limit`, or `Array.fromAsync` over a semaphore-wrapped generator) — unbounded `Promise.all` over 10k fetches is a self-DoS.
      
      ```ts
      // BAD — sequential, 10× slower than needed
      for (const id of ids) results.push(await fetchUser(id));
      // GOOD — parallel with a concurrency cap
      const limit = pLimit(8);
      const results = await Promise.all(ids.map(id => limit(() => fetchUser(id))));
      ```
      
      ## Floating promises and unhandled rejections
      
      A promise nobody awaits or `.catch`es crashes Node (default since v15) and silently drops errors in browsers.
      
      ```ts
      // BAD — floating; rejection is an unhandled crash, completion unordered
      saveAudit(event);
      return response;
      
      // GOOD — await it, or explicitly detach with a handler
      await saveAudit(event);
      // or, genuinely fire-and-forget:
      void saveAudit(event).catch(e => logger.error({ err: e }, 'audit failed'));
      ```
      
      - Enable `@typescript-eslint/no-floating-promises` and `no-misused-promises` (catches `async` callbacks passed where `void` is expected — `setTimeout`, event handlers, `array.forEach`).
      - `async` executor in `new Promise(async ...)` is a smell — throws inside it are lost; you almost never need `new Promise` at all when the API already returns promises. Promisify callbacks with `util.promisify` or `new Promise` at the lowest layer only.
      - Don't mix `.then` chains and `await` in one function; pick `await` + try/catch.
      - `return await fn()` inside try/catch is required for the catch to see the rejection; bare `return fn()` skips it (`@typescript-eslint/return-await` rule, `in-try-catch` option).
      
      ## AbortController everywhere
      
      Every cancellable operation takes an `AbortSignal`. fetch, event listeners, streams, and your own long-running functions.
      
      ```ts
      // fetch with timeout + caller cancellation
      async function getUser(id: UserId, signal?: AbortSignal): Promise<User> {
        const res = await fetch(`/api/users/${id}`, {
          signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(5000)]) : AbortSignal.timeout(5000),
        });
        if (!res.ok) throw new HttpError(res.status);
        return UserSchema.parse(await res.json());
      }
      
      // event listeners: one signal removes them all — the leak-proof pattern
      const ac = new AbortController();
      window.addEventListener('resize', onResize, { signal: ac.signal });
      el.addEventListener('click', onClick, { signal: ac.signal });
      // teardown (React useEffect cleanup, component unmount, route change):
      ac.abort();
      
      // custom operations: check + propagate
      async function processAll(items: Item[], signal: AbortSignal) {
        for (const item of items) {
          signal.throwIfAborted();
          await process(item, signal);   // propagate downward
        }
      }
      ```
      
      - `AbortSignal.timeout(ms)` replaces hand-rolled `Promise.race` timeout patterns; `AbortSignal.any([...])` composes caller-cancel + timeout.
      - Abort rejections are `DOMException` named `'AbortError'` (or `'TimeoutError'` from `.timeout`) — treat as flow control, not failure: `if (e instanceof DOMException && e.name === 'AbortError') return;`
      - React: abort in-flight fetches in effect cleanup to kill setState-after-unmount and stale-response races. Stale-response guarding via abort beats `let cancelled = true` flags.
      - Public async APIs in your codebase should accept `signal` as part of an options object. Functions doing network/FS/timers without a signal path are unkillable — that's a finding for long-running ops.
      
      ## Event loop: microtasks vs macrotasks
      
      Ordering model: run-to-completion of current task → drain ALL microtasks (promise reactions, `queueMicrotask`, MutationObserver) → render (browser) → next macrotask (`setTimeout`, I/O, UI events).
      
      - `await` yields to the microtask queue, not the event loop. Since ALL queued microtasks drain before the next macrotask, a tight loop of `await Promise.resolve()` still starves rendering, timers, and I/O. To genuinely yield to the loop use `scheduler.yield()` (browsers), `setTimeout(0)`, or `setImmediate` (Node).
      - Node ordering: `process.nextTick` runs before promise microtasks (avoid nextTick in app code — it can starve everything); `setImmediate` runs after I/O, before timers of the next loop iteration.
      - Long synchronous work blocks everything (see workers below). Chunk big loops: process N items, then `await scheduler.yield()` / `setImmediate`.
      - Zalgo: never make an API sometimes-sync, sometimes-async. If a function may return cached-sync or fetched-async, always go async (`return cached !== undefined ? Promise.resolve(cached)…` — just declare it `async`).
      
      ## Top-level await
      
      Allowed in ESM only. Implications:
      - It blocks every importer until resolution — a slow TLA in a shared module delays the whole graph. Fine for app entrypoints (config load, DB connect); avoid in library code and widely-imported modules.
      - Circular imports + TLA can deadlock or yield partially-initialized modules.
      - A rejected TLA fails module evaluation permanently — every subsequent import of that module rethrows. Wrap in try/catch with a fallback if the module must stay importable.
      - Prefer lazy init (exported `async function init()` or a memoized `getClient()`) over TLA for connections, so tests and tooling can import the module without side effects.
      
      ## Workers for CPU-bound work
      
      The event loop handles I/O concurrency; it cannot parallelize CPU. Anything >~50ms of synchronous compute (parsing huge JSON, image processing, crypto, compression, large sorts) belongs off-thread.
      
      ```ts
      // Node worker_threads pool — use piscina rather than hand-rolling
      import Piscina from 'piscina';
      const pool = new Piscina({ filename: new URL('./worker.js', import.meta.url).href });
      const hash = await pool.run({ file }, { signal });
      
      // Browser
      const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
      worker.postMessage(data);             // structured clone; use Transferable for big buffers
      worker.postMessage(buf, [buf]);       // transfer ArrayBuffer — zero copy, source neutered
      ```
      
      - postMessage structured-clones by default — copying a 100MB buffer per call erases the win; transfer `ArrayBuffer`s or use `SharedArrayBuffer` (requires COOP/COEP headers in browsers).
      - Workers have startup cost (~ms) — pool them, don't spawn per task.
      - Comlink (browser) wraps workers in async proxies and removes postMessage boilerplate.
      - Don't use workers for I/O-bound work — that's what async already does, workers just add overhead.
      
      ## Async iteration and web streams
      
      `for await...of` consumes async iterables — paginated APIs, streams, message queues — with backpressure for free (you don't pull the next chunk until you're done with this one).
      
      ```ts
      // Web Streams (standard in Node ≥18, browsers, Deno, Bun, edge runtimes)
      const res = await fetch(url, { signal });
      for await (const chunk of res.body!.pipeThrough(new TextDecoderStream())) {
        feed(chunk);   // process as it arrives — no full buffering
      }
      
      // Transform pipeline
      await readable
        .pipeThrough(new TextDecoderStream())
        .pipeThrough(toLines())          // TransformStream
        .pipeTo(destination, { signal });
      ```
      
      - Prefer Web Streams (`ReadableStream`/`WritableStream`/`TransformStream`) over Node streams in new cross-platform code; bridge legacy with `Readable.toWeb()`/`fromWeb()`.
      - Node-side pipelines: `stream/promises` `pipeline(src, transform, dst, { signal })` — handles error propagation and cleanup; never `.pipe()` chains without error handling (each `.pipe` swallows downstream errors).
      - Don't buffer whole files/bodies when output is also a stream — `await res.json()` on a 2GB body is an OOM; stream it.
      - `Array.fromAsync(asyncIterable)` (ES2024) materializes when you genuinely need the full array.
      - `ReadableStream` is async-iterable; respect cancellation: a `break` out of `for await` cancels the stream (releases the lock) — that's correct behavior, rely on it.
      
      ## Retries with backoff, abort-aware
      
      Retry only transient failures (network errors, 429/503), only idempotent operations, bounded attempts, exponential backoff with full jitter, and propagate the caller's signal so cancellation stops the retry loop too.
      
      ```ts
      async function withRetry<T>(fn: (signal: AbortSignal) => Promise<T>, opts: { attempts?: number; signal?: AbortSignal } = {}): Promise<T> {
        const { attempts = 3, signal } = opts;
        for (let i = 0; ; i++) {
          signal?.throwIfAborted();
          try {
            return await fn(signal ?? new AbortController().signal);
          } catch (e) {
            if (i >= attempts - 1 || !isTransient(e)) throw e;
            const delay = Math.random() * Math.min(1000 * 2 ** i, 10_000);   // full jitter, capped
            await scheduler.wait(delay, { signal });   // or setTimeout-promise with signal
          }
        }
      }
      ```
      
      Retrying on every error (including 400s and bugs) hammers dependencies and hides defects — classify first. Libraries: `p-retry` does this correctly; don't hand-roll in more than one place.
      
      ## Race conditions in async code
      
      `await` is a suspension point — shared state may change across it.
      
      ```ts
      // BAD — check-then-act across await; two concurrent calls both insert
      if (!(await db.userExists(email))) await db.insertUser(email);   // TOCTOU
      // GOOD — atomic at the source of truth: UNIQUE constraint + upsert/conflict handling
      
      // BAD — stale write: slower older request overwrites newer result
      onChange: async (q) => setResults(await search(q));
      // GOOD — abort the previous request
      onChange: (q) => { ac.abort(); ac = new AbortController(); search(q, ac.signal).then(setResults).catch(ignoreAbort); }
      ```
      
      - In-memory mutexes (`async-mutex`) only serialize within one process — cross-instance invariants belong in the DB/queue.
      - Cache stampede: memoize the promise, not the value, so concurrent callers share one in-flight request:
      
      ```ts
      const inflight = new Map<string, Promise<User>>();
      function getUser(id: string) {
        let p = inflight.get(id);
        if (!p) { p = fetchUser(id).finally(() => inflight.delete(id)); inflight.set(id, p); }
        return p;
      }
      ```
      
      ## Async generator cleanup and resource safety
      
      Generators suspended at `yield` still hold resources. A consumer that `break`s or throws triggers the generator's `return()` — put cleanup in `finally`:
      
      ```ts
      async function* readBatches(db: Db, signal: AbortSignal) {
        const cursor = await db.openCursor();
        try {
          while (!signal.aborted) {
            const batch = await cursor.next();
            if (!batch) return;
            yield batch;
          }
        } finally {
          await cursor.close();    // runs on break/throw/return — guaranteed
        }
      }
      ```
      
      - ES2026 explicit resource management generalizes this: `await using cursor = await db.openCursor();` with `[Symbol.asyncDispose]` on the resource — adopt for locks, files, connections as the runtimes/tsconfig (`lib: esnext.disposable`) allow.
      - Browser-side last-resort rejection telemetry: `window.addEventListener('unhandledrejection', e => { report(e.reason); e.preventDefault(); })` — report, don't suppress silently; this is monitoring, not error handling.
      
      ## Deferred patterns worth knowing
      
      - `Promise.withResolvers()` (ES2024) replaces the deferred anti-boilerplate when bridging callback/event worlds:
      
      ```ts
      const { promise, resolve, reject } = Promise.withResolvers<Payload>();
      socket.once('reply', resolve);
      socket.once('error', reject);
      return promise;
      ```
      
      - Event-to-promise: `once(emitter, 'event', { signal })` from `node:events`; in browsers, wrap `addEventListener(..., { once: true, signal })`.
      - Queue/serialize without a library: chain onto a stored promise — `queue = queue.then(() => task())` — each task starts after the previous settles; add a `.catch` so one failure doesn't poison the chain.
      - Async cleanup that must not be cancelled (audit flush, lock release): run it in `finally`, and if it's itself async inside an aborted context, detach it deliberately with its own timeout — don't pass the already-aborted signal.
      
      ## Audit checklist
      
      - [ ] `@typescript-eslint/no-floating-promises` + `no-misused-promises` enabled and passing — if not, HIGH; floating promises are silent data loss.
      - [ ] `grep -rn "Promise.all(" src/` — for each: are the ops independent? Should be `allSettled`? Is parallelism unbounded over user-controlled set sizes (HIGH — self-DoS)?
      - [ ] `grep -rn "await" src/ | grep -n "for (\|for(" -B0` → review loops: `grep -rn -A3 "for (const .* of" src/ | grep "await"` — sequential awaits that should be parallel (MEDIUM perf).
      - [ ] `grep -rn "fetch(" src/ | grep -v "signal"` — fetches without abort/timeout (MEDIUM; HIGH server-side where a hung upstream pins resources).
      - [ ] `grep -rn "addEventListener" src/` — paired removal or `{ signal }`? Unremoved listeners on long-lived targets = memory leak (MEDIUM).
      - [ ] `grep -rn "new Promise(async" src/` — lost rejections (HIGH).
      - [ ] `grep -rn "setInterval\|setTimeout" src/` — cleared on teardown? Async callbacks with try/catch?
      - [ ] `grep -rn "forEach(async\|map(async" src/` — `map(async` without surrounding `Promise.all` = floating (HIGH); `forEach(async` always wrong.
      - [ ] `grep -rn "process.nextTick" src/` — app-code use is a smell (LOW).
      - [ ] `grep -rn "\.pipe(" src/` — Node pipes without `pipeline()` error handling (MEDIUM).
      - [ ] Check-then-act across `await` on shared resources (manual review around `await` + `if` patterns) — TOCTOU (HIGH where it guards uniqueness/money).
      - [ ] Top-level `await` in shared/library modules (`grep -rn "^await \|^const .* = await" src/` at module scope) — startup coupling (LOW/MEDIUM).
      
    • 04-node-backend.md 15.3 KB
      # Node.js Backend
      
      ## Runtime choice
      
      - **Node LTS** (currently 24 active LTS, 22 in maintenance; 26 is Current and becomes LTS Oct 2026 — it ships Temporal enabled by default and undici 8): default for production backends. Largest ecosystem compatibility, slowest-moving, best observability story. Pin the major in `package.json` `engines` and `.nvmrc`/`.node-version`; CI must run the pinned version. From Node 27 the release cycle is annual and every major reaches LTS after six months as Current.
      - **Bun**: fast installs/startup/test runner; fine for tooling, scripts, and apps you've load-tested on it. Verify native-addon and edge-case Node-API compat before betting production on it.
      - **Deno**: strong security model (permission flags), built-in TS. Choose when its model fits; ecosystem friction has shrunk with npm compat but still exists.
      - Decision rule: pick per-project, write runtime-neutral code (Web APIs: `fetch`, Web Streams, Web Crypto, `AbortController`) so the choice stays reversible. Avoid runtime-specific APIs in shared libraries.
      
      ## Use the platform — drop unnecessary deps
      
      Node now ships what used to require packages. Every dep removed is supply-chain and maintenance surface removed.
      
      | Dependency | Built-in replacement (Node ≥20/22) |
      |---|---|
      | axios/node-fetch/got | global `fetch` (undici) |
      | nodemon | `node --watch` |
      | dotenv | `node --env-file=.env` (≥20.6) |
      | jest/mocha (for libs/simple apps) | `node:test` + `node:assert` |
      | chalk (basic) | `styleText` from `node:util` |
      | uuid | `crypto.randomUUID()` |
      | minimist/yargs (simple CLIs) | `util.parseArgs` |
      | glob (simple) | `fs.glob` (≥22) |
      | ws client (basic) | global `WebSocket` (≥22) |
      
      ```bash
      node --watch --env-file=.env src/server.ts   # Node ≥22.18/≥24 runs TS directly (type stripping, unflagged)
      node --test --experimental-test-coverage
      ```
      
      Keep deps that earn their weight (pino, zod, drizzle/prisma, fastify). The bar: a dep must do something nontrivial that the platform doesn't.
      
      ## Env and config: parse once, crash fast
      
      Never sprinkle `process.env.X` through the codebase — env vars are `string | undefined`, typos are silent, and defaults scatter.
      
      ```ts
      // config.ts — the only file allowed to touch process.env
      import { z } from 'zod';
      
      const Env = z.object({
        NODE_ENV: z.enum(['development', 'test', 'production']),
        PORT: z.coerce.number().int().min(1).max(65535).default(3000),
        DATABASE_URL: z.string().url(),
        LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
        STRIPE_KEY: z.string().min(1),
      });
      
      const parsed = Env.safeParse(process.env);
      if (!parsed.success) {
        console.error('Invalid environment:', parsed.error.flatten().fieldErrors);
        process.exit(1);   // crash at boot, not at 3am when the code path is hit
      }
      export const config = Object.freeze(parsed.data);
      ```
      
      - Boot-time crash on bad config is a feature: the orchestrator restarts and alerts; a lazy crash mid-request loses data.
      - Secrets never in code or committed `.env`; inject via secret manager/orchestrator. `.env` is local-dev only and gitignored.
      - No `NODE_ENV === 'production'` branches scattered in logic — derive named flags in config (`config.isDev`) and branch on those.
      
      ## HTTP server hardening
      
      Defaults are unsafe: Node's http server has lenient timeouts; frameworks accept huge bodies.
      
      ```ts
      import { createServer } from 'node:http';
      const server = createServer(app);
      server.requestTimeout = 30_000;       // whole request (default 300s — too long)
      server.headersTimeout = 10_000;       // slowloris defense (must be < requestTimeout)
      server.keepAliveTimeout = 65_000;     // > LB idle timeout (ALB 60s) to avoid 502 races
      server.maxRequestsPerSocket = 1000;
      ```
      
      - **Body limits**: cap JSON/body size at the framework (`express.json({ limit: '100kb' })`, Fastify `bodyLimit`). Unbounded bodies = trivial memory DoS. Cap per route by actual need.
      - **Behind a proxy**: set `trust proxy` correctly (exact hop count, not `true`) or rate limiting keys on spoofable `X-Forwarded-For`.
      - **Headers**: `helmet` (or hand-set: `HSTS`, `X-Content-Type-Options: nosniff`, frame-ancestors via CSP). Disable `X-Powered-By`.
      - **Rate limit** auth and expensive endpoints (`rate-limiter-flexible` backed by Redis when multi-instance — in-memory limits don't survive horizontal scaling).
      - **Validation**: every route parses body/query/params with a schema before touching them (rules/01, rules/05). Fastify + zod type-provider, or tRPC, makes this structural.
      - **Compression**: gate on response size; never compress encrypted/random content; beware BREACH when reflecting secrets in compressed responses.
      - Prefer Fastify over Express for new services: schema-first validation, structured logging built in (pino), 2-3× throughput, maintained.
      
      ## Graceful shutdown
      
      Kubernetes/ECS send SIGTERM and wait (default 30s) before SIGKILL. Dropping in-flight requests on deploy is a self-inflicted incident.
      
      ```ts
      const server = app.listen(config.PORT);
      const shutdown = async (signal: string) => {
        logger.info({ signal }, 'shutting down');
        server.close(() => logger.info('http closed'));        // stop accepting, finish in-flight
        server.closeIdleConnections();
        setTimeout(() => { logger.error('forced exit'); process.exit(1); }, 25_000).unref();
        await jobQueue.stop();          // stop pulling new work
        await db.end();                 // then close pools
        process.exit(0);
      };
      process.on('SIGTERM', () => void shutdown('SIGTERM'));
      process.on('SIGINT', () => void shutdown('SIGINT'));
      ```
      
      Order matters: (1) fail readiness probe / stop accepting, (2) drain in-flight with a deadline shorter than the orchestrator's, (3) close DB/queue/redis, (4) exit 0. `setTimeout(...).unref()` so the safety timer doesn't itself hold the process open. Long-lived connections (SSE/WebSocket) need explicit termination — `server.close` waits for them forever; track and end them.
      
      ## Health checks and readiness
      
      Liveness ≠ readiness. Liveness: "is the process alive" — answer cheaply, no dependency checks (a DB blip must not get you killed and restarted in a loop). Readiness: "should I receive traffic" — checks pool health, returns 503 during shutdown drain.
      
      ```ts
      let ready = true;                       // flipped false first thing in shutdown()
      app.get('/healthz', (_req, res) => res.status(200).send('ok'));
      app.get('/readyz', async (_req, res) => {
        if (!ready) return res.status(503).send('draining');
        const dbOk = await db.ping().then(() => true, () => false);
        res.status(dbOk ? 200 : 503).send(dbOk ? 'ok' : 'db');
      });
      ```
      
      Flip readiness to 503 at the start of shutdown, then wait one probe period before `server.close()` so the LB stops routing first — this is what makes zero-downtime deploys actually zero-downtime.
      
      ## Request context: AsyncLocalStorage
      
      Propagate request ID / user / trace context without threading a `ctx` parameter through every signature.
      
      ```ts
      import { AsyncLocalStorage } from 'node:async_hooks';
      const requestContext = new AsyncLocalStorage<{ reqId: string; userId?: string }>();
      
      app.use((req, _res, next) => {
        requestContext.run({ reqId: req.headers['x-request-id'] ?? crypto.randomUUID() }, next);
      });
      
      // anywhere downstream — no parameter drilling
      export const log = (obj: object, msg: string) =>
        logger.info({ ...requestContext.getStore(), ...obj }, msg);
      ```
      
      It survives `await`, timers, and promise chains. Use it for logging context and tracing only — not as a grab-bag service locator (hidden dependencies become untestable). OpenTelemetry's Node SDK rides the same mechanism; adopt OTel for traces rather than hand-rolling.
      
      ## Process-level error policy
      
      ```ts
      process.on('unhandledRejection', (reason) => {
        logger.fatal({ err: reason }, 'unhandled rejection');
        throw reason;   // escalate to uncaughtException path — same policy
      });
      process.on('uncaughtException', (err) => {
        logger.fatal({ err }, 'uncaught exception — exiting');
        // flush logs/telemetry synchronously if needed, then:
        process.exit(1);
      });
      ```
      
      Policy: **log, then die**. After an uncaught exception the process state is undefined (half-finished writes, corrupted singletons) — continuing risks data corruption worse than a restart. The orchestrator's job is restarting; your job is exiting loudly. Never install an `uncaughtException` handler that swallows and continues (HIGH finding). Per-request errors belong in framework error handlers — they must never reach the process level.
      
      - `process.on('warning')` → log it (catches MaxListenersExceeded, deprecations).
      - Don't call `process.exit()` in normal flow — it skips pending I/O and `finally` blocks; let the loop drain or use exit codes from the shutdown path only.
      
      ## Structured logging: pino
      
      `console.log` in services is unsearchable, unleveled, and synchronous-ish under load. pino writes newline-JSON, fast, with levels and redaction.
      
      ```ts
      import { pino } from 'pino';
      export const logger = pino({
        level: config.LOG_LEVEL,
        redact: { paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'], censor: '[redacted]' },
        // dev only: transport: { target: 'pino-pretty' }
      });
      
      // Structured fields first, message second — never string-interpolate data
      logger.info({ userId, orderId, durationMs }, 'order created');
      logger.error({ err }, 'payment failed');   // `err` key serializes stack + cause chain
      ```
      
      - One child logger per request with a request ID: `req.log = logger.child({ reqId })` (Fastify does this automatically). Propagate the ID to downstream calls (`AsyncLocalStorage` for context without parameter drilling).
      - Redact secrets at the logger, not by hoping call sites remember.
      - Never log: tokens, passwords, full card numbers, raw request bodies on auth routes, PII beyond need.
      - `pino-pretty` is a dev dependency/CLI, not production config. Production emits raw JSON to stdout; the platform ships it.
      
      ## Worker pools and not blocking the loop
      
      One Node process = one JS thread. A 200ms synchronous task means every concurrent request waits 200ms.
      
      - Known CPU work (hashing, image resize, PDF gen, big JSON.parse, compression): `piscina` worker pool (rules/03). Size pool ≈ cores − 1.
      - `crypto.scrypt`/`bcrypt` async variants use the libuv threadpool — never the `*Sync` variants in request paths. Bump `UV_THREADPOOL_SIZE` (default 4!) if crypto/DNS/fs-heavy.
      - Banned in request paths: `fs.*Sync`, `child_process.execSync`, `zlib.*Sync`, `JSON.parse` of multi-MB payloads (cap body size instead), synchronous template rendering of huge documents.
      - Detect blocking in production: monitor event-loop delay with `perf_hooks.monitorEventLoopDelay()` (alert at p99 > ~100ms), or `blocked-at` in staging to get stacks.
      - Multi-core: prefer N processes via the orchestrator (K8s replicas) over in-process `cluster`; keep processes single-purpose.
      
      ## Background work in services
      
      - In-process `setInterval` jobs are lost on restart, duplicated across replicas, and drift. Anything that must run exactly/at-least once per schedule belongs in a job queue (BullMQ on Redis, pg-boss on Postgres, or the platform's scheduler) with: idempotent handlers, explicit retry/backoff policy, dead-letter handling, and per-job timeouts.
      - If a lightweight in-process ticker is genuinely fine (cache refresh, metrics flush): wrap the callback in try/catch (a thrown error in a bare `setInterval` callback is an uncaught exception → process exit policy kicks in), `.unref()` it so it can't hold shutdown, and guard against overlap (skip if previous run still in flight).
      
      ```ts
      let running = false;
      const timer = setInterval(() => {
        if (running) return;
        running = true;
        refreshCache().catch(e => logger.error({ err: e }, 'refresh failed')).finally(() => { running = false; });
      }, 30_000);
      timer.unref();
      ```
      
      - Long-running request work (report generation, imports): return `202 Accepted` + job ID + status endpoint; don't hold an HTTP request open for minutes against every timeout in the chain.
      
      ## Outbound calls: pools, timeouts, retries
      
      Your service is only as reliable as its slowest dependency. Every outbound call gets:
      - **Timeout**: `AbortSignal.timeout(ms)` on fetch; statement timeout on DB queries. No infinite waits — a hung upstream plus no timeout equals your own outage.
      - **Bounded retries with jittered backoff**, idempotent operations only; honor `Retry-After`. Retrying non-idempotent POSTs duplicates orders — use idempotency keys.
      - **Connection pooling**: undici `Agent`/`Pool` for high-volume HTTP to fixed origins; DB pool sized deliberately (start ~10 per instance; pool_size × instances must stay under the DB's max_connections — the default-100 Postgres ceiling is hit by autoscaling, not load).
      - **Circuit breaking** on flapping dependencies (opossum) so you fail fast instead of queueing doomed work.
      
      ```ts
      const res = await fetch(upstream, { signal: AbortSignal.timeout(3000) });
      if (res.status >= 500) throw new UpstreamError(res.status);   // retry layer decides
      ```
      
      ## Native ESM in Node
      
      - `"type": "module"` in package.json. `__dirname`/`__filename` don't exist — use `import.meta.dirname` / `import.meta.filename` (Node ≥20.11), or `new URL('./file', import.meta.url)` for asset paths.
      - JSON imports: `import data from './data.json' with { type: 'json' }`.
      - Don't mix: a stray `require` in ESM throws; CJS deps import fine via default import. Publishing libraries: ship ESM; add CJS only if your consumers truly need it (use tsup/unbuild dual output, verify with `attw`).
      - Dynamic `import()` works in both module systems — it's the migration bridge and the lazy-loading tool.
      
      ## Audit checklist
      
      - [ ] `grep -rn "process.env" src/ --include="*.ts" | grep -v "config\|env.ts"` — env access outside the config module (MEDIUM); no schema validation of env at boot (HIGH).
      - [ ] `grep -rn "Sync(" src/ | grep -v "test\|script"` — `*Sync` calls in server code (HIGH in request paths).
      - [ ] Server timeouts: `grep -rn "headersTimeout\|requestTimeout\|keepAliveTimeout" src/` — absent = slowloris-exposed defaults (MEDIUM).
      - [ ] Body limits configured (`grep -rn "bodyLimit\|limit:" src/`) — unbounded body parsing (HIGH, DoS).
      - [ ] `grep -rn "SIGTERM" src/` — no graceful shutdown handler = dropped requests on every deploy (MEDIUM).
      - [ ] `grep -rn "uncaughtException" src/` — handler that doesn't exit (HIGH); no `unhandledRejection` policy at all (MEDIUM).
      - [ ] `grep -rn "console.log\|console.error" src/ | grep -v test` — in services, replace with pino (LOW; MEDIUM if logging objects with secrets).
      - [ ] Logger redaction configured? `grep -rn "redact" src/` — logging auth headers/bodies without redaction (HIGH).
      - [ ] `grep -rn "trust proxy" src/` and rate-limiter keying — spoofable client IP (MEDIUM).
      - [ ] `package.json`: `engines.node` pinned; deps that duplicate platform built-ins (axios, dotenv, uuid, nodemon) — removable (LOW).
      - [ ] `grep -rn "bcrypt.hashSync\|scryptSync\|pbkdf2Sync" src/` — sync crypto in request path (HIGH).
      - [ ] `grep -rn "process.exit" src/ | grep -v "config\|shutdown"` — exits mid-flow skipping cleanup (MEDIUM).
      - [ ] Readiness vs liveness probes distinct; readiness flips during drain (`grep -rn "readyz\|readiness" src/`) — single do-everything healthcheck (LOW/MEDIUM).
      - [ ] Outbound fetch/DB calls without timeouts (`grep -rn "fetch(" src/ | grep -v signal`; DB client statement_timeout) — MEDIUM, HIGH for critical paths.
      - [ ] Retry logic on non-idempotent operations without idempotency keys (MEDIUM/HIGH if money).
      - [ ] DB pool size × replica count vs database max_connections — documented anywhere? (LOW).
      
    • 05-security.md 20.1 KB
      # JavaScript/TypeScript Security
      
      Severity here maps to exploitability: attacker-controlled input reaching a sink = CRITICAL/HIGH; hardening gaps = MEDIUM; defense-in-depth = LOW.
      
      ## XSS: know your sinks
      
      XSS = untrusted data reaching an HTML/JS execution sink. Frameworks escape by default; every escape hatch is a sink.
      
      Sinks to treat as hostile-by-default:
      - `element.innerHTML`, `outerHTML`, `insertAdjacentHTML`, `document.write`
      - React `dangerouslySetInnerHTML`, Vue `v-html`, Angular `bypassSecurityTrust*`, Svelte `{@html}`
      - `eval`, `new Function`, string args to `setTimeout`/`setInterval`
      - `<a href>`/`location` assignment with user data — `javascript:` URLs
      - jQuery `$(userInput)`, `.html()`
      
      ```tsx
      // BAD — stored XSS
      <div dangerouslySetInnerHTML={{ __html: user.bio }} />
      el.innerHTML = `<b>${query}</b>`;
      
      // GOOD — default escaping; textContent for DOM
      <div>{user.bio}</div>
      el.textContent = query;
      
      // HTML genuinely required (rich text/markdown)? Sanitize with DOMPurify at render time
      import DOMPurify from 'dompurify';
      <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }) }} />
      
      // BAD — javascript: URL slips through React's escaping
      <a href={user.website}>site</a>
      // GOOD — allowlist protocols
      const safeUrl = (u: string) => { try { const p = new URL(u); return ['https:', 'http:', 'mailto:'].includes(p.protocol) ? u : '#'; } catch { return '#'; } };
      ```
      
      - Sanitize at output/render, not at input (input-sanitized data gets corrupted and re-encoded wrongly across contexts).
      - Server-rendered HTML embedding JSON state: `JSON.stringify(state).replaceAll('<', '\\u003c')` to block `</script>` breakout.
      - Adopt Trusted Types where targets allow (`require-trusted-types-for 'script'` CSP) — it turns DOM-sink misuse into runtime errors.
      - `eval`/`new Function` on anything dynamic is CRITICAL. There is no safe "sandboxed eval" in-process (Node `vm` is NOT a security boundary — escapes are trivial; use isolated processes/`isolated-vm`/WASM).
      
      ## CSP integration
      
      Ship a Content-Security-Policy on every HTML response; it converts many XSS bugs from CRITICAL to mitigated.
      
      ```
      Content-Security-Policy:
        default-src 'self';
        script-src 'self' 'nonce-{random}' 'strict-dynamic';
        object-src 'none'; base-uri 'none'; frame-ancestors 'none';
      ```
      
      - Nonce-based `strict-dynamic` beats allowlist CSP (allowlists are bypassable via JSONP/open redirects on allowed hosts). Generate a fresh nonce per response; templating/framework injects it on `<script>` tags.
      - `'unsafe-inline'` in `script-src` makes CSP decorative (finding: MEDIUM). `'unsafe-eval'` enables the eval family — required only by legacy libs; eliminate.
      - Roll out with `Content-Security-Policy-Report-Only` + a report endpoint, then enforce.
      - `frame-ancestors` replaces `X-Frame-Options`; `base-uri 'none'` blocks `<base>` hijack of relative script URLs.
      
      ## Prototype pollution
      
      Writing to `__proto__`/`constructor.prototype` via attacker-controlled keys poisons every object — leading to auth bypass (`{}.isAdmin === true`), DoS, sometimes RCE via gadget chains.
      
      Vulnerable patterns: recursive merge/extend/clone of untrusted JSON, `obj[a][b] = v` with attacker-controlled `a`, lodash `set`/`merge` with untrusted paths, query-string parsers building nested objects.
      
      ```ts
      // BAD — classic vulnerable deep merge
      function merge(target: any, src: any) {
        for (const k in src) {
          if (typeof src[k] === 'object') merge(target[k] ??= {}, src[k]);  // k = "__proto__" pollutes
          else target[k] = src[k];
        }
      }
      
      // GOOD — defenses, in order of preference:
      // 1. Don't deep-merge untrusted input. Parse with zod (strips unknown keys, fixed shape).
      const body = BodySchema.parse(await req.json());
      // 2. If you must merge dynamic keys: block the dangerous ones and use null-prototype targets
      const DANGEROUS = new Set(['__proto__', 'constructor', 'prototype']);
      if (DANGEROUS.has(key)) continue;
      const map = Object.create(null);          // no prototype to pollute
      // 3. Maps for dynamic keyed storage (rules/02). 4. node --disable-proto=delete as hardening.
      ```
      
      - `JSON.parse` itself is safe (`__proto__` becomes an own property) — the pollution happens in subsequent merge/assign logic.
      - Check dependencies: historic offenders are deep-merge utilities, config loaders, and qs-style parsers. `Object.freeze(Object.prototype)` is a blunt last-resort hardening some services use.
      
      ## npm supply chain
      
      The dependency tree is your attack surface; install scripts run arbitrary code on `npm install` (developer machines and CI). Worm campaigns like the 2025 Shai-Hulud worm spread exactly this way; the March 2026 axios compromise published malicious versions with a stolen npm token, bypassing the project's trusted-publishing setup — caught within a day, which is exactly what install cooldowns absorb; and the June 2026 Miasma wave (a Shai-Hulud derivative, 32+ `@redhat-cloud-services` packages) ran code at install via a phantom `binding.gyp` — the implicit `node-gyp rebuild` needs no declared install script — published through a compromised OIDC trusted-publishing workflow with valid SLSA provenance.
      
      - **Lockfile committed and exact**: `package-lock.json`/`pnpm-lock.yaml` in git; CI installs with `npm ci` / `pnpm install --frozen-lockfile` — never bare `npm install` in CI.
      - **Disable install scripts by default**: `npm config set ignore-scripts true` (or `.npmrc: ignore-scripts=true`) — this also skips the implicit `binding.gyp`/node-gyp path; pnpm ≥10 blocks them by default with an allowlist — `allowBuilds` since **v11**, which **removed** `onlyBuiltDependencies`/`neverBuiltDependencies`/`ignoreDepScripts`; keep `strictDepBuilds` (default `true` since v10.3.0) so an unreviewed build script **exits non-zero** instead of warning; npm ≥12 (July 2026) blocks dependency install scripts including implicit node-gyp rebuilds by default, and refuses git and remote-URL tarball dependencies unless `--allow-git`/`--allow-remote` — build the allowlist before upgrading with `npm approve-scripts` (npm ≥11.16 warns). Allow per-package only what genuinely needs to build (esbuild, sharp).
      - **Cooldown**: don't install or auto-merge dependency updates the day they publish; most hijacked versions are caught within days. Package managers now enforce this natively: pnpm 11 defaults `minimumReleaseAge` to 1440 minutes (1 day — don't opt out without reason); npm CLI ≥11.10 has `min-release-age` (days) in config; plus Renovate `minimumReleaseAge` (e.g. `7 days`) / Dependabot cooldown for update PRs.
      - **Provenance & audit**: prefer packages publishing npm provenance (Sigstore attestation) — but provenance proves the publish path, not code safety: Miasma shipped malware with valid SLSA attestations from a compromised trusted-publishing workflow; `npm audit --omit=dev` in CI with a triage policy (fail on high/critical with no fix-path exception file); `osv-scanner` or Socket for behavioral flags (new maintainer, install script added, network in install).
      - **Minimal deps**: every dep is trust granted to its maintainers and their deps transitively. Before adding: is it <100 lines you could own? Does the platform do it (rules/04 table)? Check maintenance, weekly downloads, dependency count.
      - **Typosquatting**: verify exact names on add; scoped packages (`@org/x`) reduce risk. Pin GitHub Actions to commit SHAs, not tags.
      - **Publishing**: trusted publishing (OIDC from CI — `npm trust` since CLI 11.10 configures it across packages in bulk) over long-lived tokens; npm's staged publishing adds a human 2FA approval gate before a version goes live — enable it for high-blast-radius packages (a stolen token alone then can't ship a release). `files` allowlist in package.json so secrets/configs never ship in the tarball.
      
      ## ReDoS
      
      Backtracking regexes with nested/overlapping quantifiers go exponential on crafted input — one request pins a CPU (and on Node, the whole event loop: total DoS).
      
      ```ts
      // BAD — (a+)+ catastrophic backtracking; 30 chars of 'aaaa...!' hangs the process
      const valid = /^(\w+\s?)*$/.test(userInput);
      // BAD — overlapping alternation
      /^(.*,)*.*$/
      
      // GOOD options:
      // 1. Linear-time by construction: no nested quantifiers over overlapping sets
      const valid = /^[\w\s]*$/.test(userInput);
      // 2. Length-cap input BEFORE regexing
      if (input.length > 256) reject();
      // 3. Non-backtracking engine for complex patterns: RE2 (node-re2)
      // 4. Don't regex what a parser should parse (emails: maxlength + one '@' + send a verification mail)
      ```
      
      - Lint: `eslint-plugin-regexp` (includes ReDoS detection) or `recheck`/`redos-detector` in CI on any regex touching user input.
      - The `v`/`u` flags don't fix backtracking. Regexes in hot paths compiled once (top-level `const`), not per call.
      
      ## Tokens and client-side auth
      
      - **Session/refresh tokens never in `localStorage`/`sessionStorage`** — any XSS exfiltrates them. Use cookies: `HttpOnly; Secure; SameSite=Lax` (or `Strict`), `Path` scoped, `__Host-` prefix.
      - SameSite is CSRF defense-in-depth, not complete: keep CSRF tokens (or strictly enforce custom-header + CORS preflight) for state-changing routes if any non-SameSite path exists.
      - If an SPA must hold an access token in JS (third-party API): keep it in memory only, short-lived (≤15min), refresh via httpOnly-cookie refresh token; accept that XSS can use (not just steal) it — XSS prevention remains the real control.
      - **Verify JWTs server-side properly**: pin the algorithm (`{ algorithms: ['RS256'] }` — never accept `alg` from the token; `none` and HS/RS confusion attacks), validate `iss`, `aud`, `exp`, clock skew. Use `jose`. Don't put secrets in JWT payloads — they're only base64.
      - Authorization on every request server-side; client-side route guards are UX, not security.
      
      ```ts
      // Setting the session cookie — the full attribute set, not a subset
      res.setHeader('Set-Cookie',
        `__Host-session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900`);
      ```
      
      - `__Host-` prefix forces Secure + no Domain attribute + Path=/ — blocks subdomain cookie-tossing.
      - Rotate session IDs on login/privilege change (session fixation); server-side revocation list or short-lived JWT + refresh rotation with reuse detection.
      - CSRF for cookie-authed JSON APIs: require a custom header (e.g. `X-Requested-With`) and strict CORS — preflight enforcement makes cross-origin forgery fail; forms still need synchronizer tokens.
      - CORS: never `Access-Control-Allow-Origin: *` with `Allow-Credentials: true` (browsers reject it; reflecting `Origin` unvalidated recreates the hole — allowlist exact origins).
      
      ## Secrets in code and client bundles
      
      - Anything in frontend code ships to the attacker: API keys in `VITE_*`/`NEXT_PUBLIC_*` vars are public by definition — only put genuinely-public keys there (analytics write keys, maps keys with referrer locks). Server-only secrets must never appear in client-reachable modules; Next.js server/client boundary violations leak env into the bundle.
      - `grep` the built bundle for key prefixes (`sk_live`, `AKIA`, `ghp_`, `AIza`) as a release gate; gitleaks/trufflehog pre-commit and in CI for the repo itself.
      - Error responses: never echo stack traces, SQL, or internal paths to clients in production — log them server-side with the request ID, return the ID in the error body for correlation.
      
      ## postMessage and cross-origin
      
      ```ts
      // BAD — any window can send this; acting on it = universal XSS/state tampering
      window.addEventListener('message', (e) => applySettings(e.data));
      // BAD — broadcasting secrets to whoever holds the window
      otherWindow.postMessage(token, '*');
      
      // GOOD — verify origin on receive, target origin on send, validate payload
      window.addEventListener('message', (e) => {
        if (e.origin !== 'https://trusted.example.com') return;
        const msg = MessageSchema.safeParse(e.data);
        if (msg.success) handle(msg.data);
      });
      iframe.contentWindow?.postMessage(payload, 'https://child.example.com');
      ```
      
      Treat `e.data` as untrusted input even from trusted origins (the trusted page may itself be compromised). Same discipline for `BroadcastChannel` and `window.opener` (use `rel="noopener"` on external links).
      
      ## Command injection via child_process
      
      ```ts
      // BAD — exec runs through a shell; filename = "x; rm -rf /" executes
      exec(`convert ${filename} out.png`);
      // BAD — shell:true reintroduces the hole in spawn
      spawn('convert', [filename], { shell: true });
      
      // GOOD — execFile/spawn with array args, no shell: arguments are never parsed
      import { execFile } from 'node:child_process';
      const { stdout } = await promisify(execFile)('convert', [filename, 'out.png'], { timeout: 10_000 });
      ```
      
      - `exec`/`execSync` with any interpolated value is CRITICAL. `execFile`/`spawn` (no `shell`) pass args directly to the binary.
      - Still validate the arg itself: allowlist characters/paths (argument injection — `--flag`-shaped filenames — can still subvert tools; prepend `--` where supported).
      - Path traversal cousin: joining user input into paths — `const p = path.resolve(base, name); if (!p.startsWith(base + path.sep)) reject();`
      - Same family: never interpolate into SQL (parameterized queries only), `Function`, YAML `load` (use `safeLoad` semantics), or `vm`.
      - **Node deprecated the dangerous spelling itself.** `DEP0190` — "Passing `args` to
        the `node:child_process` module's `execFile()` and `spawn()` methods with the
        `shell` option enabled is deprecated … the arguments are not properly escaped when
        passed to the shell". Measured on Node 22: `spawnSync('/bin/echo', ['$HOME'],
        {shell:true})` prints the expanded home directory **and emits the DEP0190 runtime
        warning**, while the same call without `shell` prints the literal `$HOME`. Treat a
        DEP0190 warning in logs or CI as a finding, not noise — it marks a live injection
        sink.
      - **`timeout:` bounds your wait, not the process tree, and may report no error.**
        Measured on Node 22 with a child that exits immediately after spawning a grandchild
        holding stdout: `execFile(..., {timeout: 300})` called back at **305 ms** — the
        deadline works — but the **grandchild was still running**, and `err` was **`null`**,
        because the direct child had exited 0. So the caller cannot tell from the callback
        that the deadline fired at all. If the child may fork, spawn it with
        `detached: true` and kill the process **group** (`process.kill(-child.pid)`), and
        decide explicitly what a timeout means for your caller rather than inferring it from
        `err`.
      
      ## Open redirects and file uploads
      
      Open redirect (`/login?next=https://evil.example`) launders phishing through your domain and chains into OAuth token theft.
      
      ```ts
      // BAD
      res.redirect(req.query.next as string);
      // GOOD — relative-path allowlist; reject absolute/protocol-relative
      const next = String(req.query.next ?? '/');
      res.redirect(next.startsWith('/') && !next.startsWith('//') && !next.includes('\\') ? next : '/');
      ```
      
      File uploads:
      - Validate by magic bytes (`file-type` package), not extension or client `Content-Type`; both are attacker-controlled.
      - Generate server-side filenames (`crypto.randomUUID()` + validated extension); never use the client filename in paths (traversal) or HTML (XSS).
      - Size-cap at the parser (multipart limits), store outside the web root / in object storage, serve with `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` for user content; SVGs are XSS vectors — sanitize or serve from a sandboxed origin.
      - Images: re-encode (sharp) to strip embedded payloads/EXIF.
      
      ## SSRF and server-side validation
      
      - Every inbound payload schema-parsed at the boundary (rules/01) — including webhooks (verify signatures: Stripe/GitHub HMAC) and headers you act on.
      - User-supplied URLs the server fetches (webhooks, importers, avatars): allowlist protocols (`https:` only), resolve DNS and block private ranges (127/8, 10/8, 172.16/12, 192.168/16, 169.254/16 — cloud metadata `169.254.169.254`), block redirects-to-private (re-check after each redirect, `redirect: 'manual'`), pin timeouts and response-size caps. Library: `ssrf-req-filter` or equivalent egress proxy.
      - Mass assignment: never `Model.update(req.body)` — schema-pick the allowed fields (`z.object({...}).strict()`).
      
      ## Timing and crypto hygiene
      
      - Compare secrets (HMAC signatures, API keys, tokens) with `crypto.timingSafeEqual` (equal-length buffers — hash both sides first if lengths vary), never `===` (timing oracle).
      - Randomness for anything security-relevant (tokens, IDs in URLs, reset codes): `crypto.randomUUID()` / `crypto.getRandomValues()` / `crypto.randomBytes` — never `Math.random()` (predictable, seedable state recovery is practical).
      - Password hashing: argon2id (or scrypt/bcrypt with sane cost), async variants only (rules/04); never SHA-256-of-password, never homegrown.
      - Web Crypto (`crypto.subtle`) for in-app encryption/signing; AES-GCM with unique IVs per encryption (IV reuse with GCM is catastrophic); keys from KMS/secret manager, not constants.
      
      ## Audit checklist
      
      - [ ] `grep -rn "innerHTML\|outerHTML\|insertAdjacentHTML\|document.write" src/` — each with non-constant input is HIGH/CRITICAL; constant strings LOW.
      - [ ] `grep -rn "dangerouslySetInnerHTML\|v-html\|{@html}\|bypassSecurityTrust" src/` — sanitized with DOMPurify at render? Unsanitized user/db content = CRITICAL (stored XSS).
      - [ ] `grep -rn "eval(\|new Function(\|setTimeout(['\"\`]\|setInterval(['\"\`]" src/` — CRITICAL with dynamic input.
      - [ ] `grep -rn "href={" src/ --include="*.tsx"` — user-controlled hrefs without protocol allowlist (`javascript:`) = HIGH.
      - [ ] `grep -rn "localStorage.setItem\|sessionStorage.setItem" src/ | grep -i "token\|jwt\|session\|auth\|key"` — HIGH.
      - [ ] `grep -rn "postMessage" src/` — `'*'` target with sensitive data (HIGH); message listener without origin check (HIGH).
      - [ ] `grep -rn "exec(\|execSync(" src/` — template/concat input = CRITICAL; migrate to execFile array form.
      - [ ] `grep -rn "child_process" src/ | grep "shell"` — `shell: true` (HIGH).
      - [ ] Deep-merge of request data: `grep -rn "merge(\|deepmerge\|Object.assign" src/` near `req.body`/`json()` — prototype pollution exposure (HIGH); `grep -rn "__proto__" src/` in tests/guards is good signal of awareness.
      - [ ] `grep -rn "jwt.verify\|jwtVerify" src/` — algorithm pinned? `aud`/`iss` checked? `grep -rn "algorithms" src/` absent = HIGH.
      - [ ] Regex on user input: `grep -rn "new RegExp(" src/` (dynamic patterns = ReDoS + injection risk, HIGH); run `eslint-plugin-regexp`/recheck over static patterns.
      - [ ] CSP present? `grep -rn "Content-Security-Policy" src/` — absent on HTML-serving apps (MEDIUM); contains `unsafe-inline`/`unsafe-eval` in script-src (MEDIUM).
      - [ ] Lockfile in git; CI uses `npm ci`/frozen lockfile; install scripts blocked (`.npmrc` `ignore-scripts`, pnpm allowlist, or npm ≥12 defaults + committed `approve-scripts` allowlist); install cooldown active (pnpm 11 `minimumReleaseAge` default, npm ≥11.10 `min-release-age`, or Renovate/Dependabot) (each absent: MEDIUM).
      - [ ] `grep -n "git+\|git://\|github:\|https://.*\.tgz" package.json` — git-URL or remote-tarball dependencies: unauditable, refused by npm ≥12 without `--allow-git`/`--allow-remote` (MEDIUM).
      - [ ] Server-side fetch of user-supplied URLs: private-IP/metadata blocking + redirect handling (absent = HIGH, SSRF).
      - [ ] Webhook handlers: signature verification before parsing (absent = HIGH).
      - [ ] `grep -rn "Allow-Origin" src/` — `*` with credentials or unvalidated Origin reflection (HIGH).
      - [ ] Built client bundle: `grep -rE "sk_live|AKIA|ghp_|-----BEGIN" dist/` — leaked secrets (CRITICAL). Repo: gitleaks in CI (absent = MEDIUM).
      - [ ] `grep -rn "NEXT_PUBLIC_\|VITE_" src/ .env*` — server secrets under public prefixes (CRITICAL).
      - [ ] Production error handler leaks stacks/SQL to clients (`grep -rn "err.stack\|error.stack" src/` in response paths) — MEDIUM.
      - [ ] Cookies: `grep -rn "Set-Cookie\|res.cookie" src/` — missing HttpOnly/Secure/SameSite on session cookies (HIGH).
      - [ ] `grep -rn "res.redirect\|window.location.*=\|location.href.*=" src/` with request-derived values — open redirect (MEDIUM/HIGH near auth flows).
      - [ ] Upload handlers: extension/MIME-only validation, client filename used in path (`grep -rn "originalname\|file.name" src/`) — HIGH.
      - [ ] `grep -rn "Math.random" src/` near token/id/code generation — HIGH; `grep -rn "=== .*signature\|signature ===" src/` — timing-unsafe compare (MEDIUM).
      
    • 06-performance.md 16.3 KB
      # Performance: Bundles, React, Memory, Event Loop
      
      Measure before optimizing: bundle analyzer for size, React Profiler/DevTools for renders, Chrome Performance + heap snapshots for memory, `monitorEventLoopDelay` for Node. Optimizations without a measurement are style choices.
      
      ## Bundle discipline
      
      Every shipped KB is parse/compile/execute time on the median phone, not just transfer.
      
      - **Budget and analyze in CI**: `rollup-plugin-visualizer` / `webpack-bundle-analyzer` / `vite-bundle-visualizer`; budget per route (e.g. ≤200KB gz initial JS) enforced with `size-limit` so regressions fail PRs.
      - **Tree-shaking-friendly code**: ESM only; no module-level side effects (top-level mutations, auto-registration); `"sideEffects": false` in package.json (list CSS/polyfill exceptions). Barrel files (`index.ts` re-exporting everything) defeat dev-server perf and often shaking — import from the specific module, or use `optimizePackageImports`/eslint `no-barrel-files` policy.
      
      ```ts
      // BAD — pulls whole lib if package isn't shakeable; barrels chain-load the world
      import _ from 'lodash';
      import { Button } from '@/components';          // barrel of 200 components
      
      // GOOD
      import debounce from 'lodash-es/debounce';      // or write the 10-liner yourself
      import { Button } from '@/components/button';
      ```
      
      - **Dynamic import for conditional weight**: routes, modals, charts, editors, anything below the fold or behind interaction.
      
      ```tsx
      const ChartPanel = lazy(() => import('./ChartPanel'));   // + <Suspense fallback>
      // Non-React: const { parse } = await import('heavy-parser');
      ```
      
      - **Dependency weight check before adding**: bundlephobia/pkg-size; prefer date-fns over moment, valibot/zod-mini where bundle-critical. Duplicate-version check: `pnpm dedupe`, analyzer's duplicates view.
      - Ship modern JS (`target: 'es2022'`-ish browserslist) — transpiling classes/async down for dead browsers costs 20-30% size.
      - Fonts/images dwarf JS in LCP terms — but that's outside this file's scope; don't let JS micro-opts distract from a 2MB hero image.
      
      ## Startup: loading and hydration
      
      - `<script type="module">` defers by default; never blocking scripts in `<head>` without `defer`/`async`. `modulepreload` the critical graph; `rel=preconnect` to API/CDN origins.
      - Hydration cost scales with shipped component tree: RSC/islands architectures (Next App Router, Astro) exist to ship less of it — interactivity leaves only (`'use client'` discipline below).
      - Lazy-hydrate below-the-fold islands (visible-trigger) where the framework supports it; don't lazy-load anything needed for first interaction (you trade LCP for INP).
      - Third-party scripts are the usual LCP/INP killers: load analytics/chat after interactive (`next/script strategy="lazyOnload"` or equivalent), or move to a worker via Partytown when compatible.
      - Prefetch on intent: route prefetch on link hover/viewport (framework default — verify it's not disabled), data prefetch for the predictable next step. Cheap wins that beat any memoization.
      
      ### Profiling toolbox
      
      | Question | Tool |
      |---|---|
      | What's in the bundle? | vite-bundle-visualizer / webpack-bundle-analyzer, `size-limit` in CI |
      | Why is this interaction slow? | Chrome Performance panel (look for long tasks), React Profiler |
      | What re-rendered and why? | React DevTools Profiler, "record why" enabled |
      | Where's the memory going? | DevTools Memory: snapshot diff, allocation timeline |
      | Is the Node loop blocked? | `monitorEventLoopDelay`, clinic flame / 0x under load |
      | Real-user numbers? | web-vitals → RUM (LCP, INP, CLS) — lab numbers lie about phones |
      
      ## React rendering
      
      A render is a function call, not a DOM write — cheap-ish, but O(subtree) and they cascade. Hunt structural causes before memoizing.
      
      Ordered playbook:
      1. **State down**: state used by one subtree must live there, not in a page-level component re-rendering everything per keystroke.
      2. **Children as props / composition**: a component taking `children` doesn't re-render them when its own state changes — lift expensive subtrees out of frequently-updating wrappers.
      
      ```tsx
      // BAD — every tick re-renders <ExpensiveTree>
      function Page() { const t = useTick(); return <div><Clock t={t} /><ExpensiveTree /></div>; }
      // GOOD — move ticking state into Clock; ExpensiveTree untouched
      function Page() { return <div><Clock /><ExpensiveTree /></div>; }
      ```
      
      3. **Subscribe narrowly**: context splits (state vs dispatch), or selector-based stores (zustand/jotai) so components re-render on their slice only. A single fat context is the classic whole-app re-render.
      4. **Then memoize, judiciously**: `React.memo` on expensive leaf/list components; `useMemo` for expensive computations; `useCallback` only to stabilize props of memoized children or effect deps. Memoizing everything adds comparison cost + complexity for nothing — and one unstable prop (inline object/array) silently voids `memo`. React Compiler 1.0 (stable since Oct 2025) auto-memoizes — when it's enabled, delete manual memo noise rather than adding more.
      5. **Verify with the Profiler** (record → interact → check "why did this render") before and after.
      
      Other React essentials:
      - **Keys**: stable identity (`item.id`), never array index for reorderable/insertable lists (state and DOM get misattached), never `Math.random()` (full remount per render).
      - **Transitions**: wrap non-urgent updates (`startTransition`, `useDeferredValue` for derived expensive renders) so typing stays responsive while results lag gracefully.
      - **Server Components mental model** (Next.js App Router etc.): RSC run on the server only and ship zero JS — default everything server; add `'use client'` only at interactivity leaves. Don't pass non-serializable props across the boundary; push `'use client'` boundaries as deep as possible. Data-fetch in server components (no client waterfall, no useEffect-fetch).
      - **Effects**: `useEffect` is for synchronizing with external systems, not for derived state (compute during render) or event logic (put in handler). Effect-chains (`setState` in effect triggering next effect) cause render cascades — derive instead. `useEffectEvent` (React 19.2+) reads latest props/state inside an effect without adding them to deps — use it instead of dep-list gymnastics or stale-closure refs.
      - Avoid layout thrash: batch DOM reads then writes; in raw-DOM code interleaved `offsetHeight`/style writes force sync reflow per iteration.
      
      ### Re-render hunting workflow
      
      1. React DevTools Profiler → enable "Record why each component rendered" → record the slow interaction.
      2. Sort commits by duration; in the flamegraph find wide bars that shouldn't have rendered (props visually unchanged).
      3. Common causes, in observed frequency order:
         - New object/array/function identity per render passed as prop: `<List style={{ margin: 8 }} onSelect={() => ...} />` — hoist constants, memoize handlers only when the child is memoized.
         - Context value rebuilt each render: `<Ctx.Provider value={{ user, setUser }}>` — memoize the value object, or split into two contexts.
         - Store subscription too broad: `const state = useStore()` instead of `useStore(s => s.cartCount)`.
         - Parent state that belongs in a child (form input state at page level).
      4. Fix the structural cause; re-profile; only then add `memo` to remaining hot leaves.
      
      ```tsx
      // BAD — context identity changes every render; every consumer re-renders
      function App() {
        const [user, setUser] = useState<User | null>(null);
        return <UserCtx.Provider value={{ user, setUser }}>{children}</UserCtx.Provider>;
      }
      // GOOD — stable value; even better: separate state and dispatch contexts
      const value = useMemo(() => ({ user, setUser }), [user]);
      ```
      
      Measure interaction health with INP (Interaction to Next Paint): long tasks >50ms between input and paint are the budget violations. `PerformanceObserver` with `{ type: 'event', durationThreshold: 40 }` or web-vitals library in RUM; break up long handlers with `await scheduler.yield()` between logical phases.
      
      ## Long lists: virtualization
      
      DOM nodes are the cost: 10k rows × 20 nodes kills layout/paint regardless of framework.
      
      - > ~200-500 rendered items (or heavy rows) → virtualize: `@tanstack/virtual`, `react-window`. Renders only the viewport ± overscan.
      - CSS `content-visibility: auto` + `contain-intrinsic-size` is a zero-JS alternative for long static pages.
      - Paginate/infinite-scroll at the data layer too — don't ship 50k records to the client to virtualize their DOM.
      - Virtualization breaks Ctrl-F and screen-reader sequence; for short lists just render them.
      
      ## Debounce and throttle
      
      - **Debounce** (trailing): act after input settles — search-as-you-type (200-300ms), autosave, resize-end.
      - **Throttle**: act at most every N ms during continuous events — scroll position, drag, mousemove.
      - Prefer platform primitives when they fit: `IntersectionObserver` over scroll handlers, `ResizeObserver` over resize handlers — they're off-main-thread scheduled and don't need throttling.
      
      ```tsx
      // React: keep the timer in a ref; cancel on unmount; or use TanStack Pacer/use-debounce
      const debouncedSearch = useMemo(() => debounce((q: string) => run(q), 250), []);
      useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]);
      ```
      
      Recreating the debounced fn every render (inline `debounce(...)` in the component body without memo) resets the timer each keystroke — the #1 debounce bug. For data fetching, debounce + abort previous request (rules/03) together.
      
      ## Caching and memoization (non-React)
      
      - Memoize pure-expensive functions with bounded caches; an unbounded memo on user-keyed input is a leak (below).
      
      ```ts
      import { LRUCache } from 'lru-cache';
      const cache = new LRUCache<string, Report>({ max: 500, ttl: 60_000 });
      async function getReport(id: string): Promise<Report> {
        const hit = cache.get(id);
        if (hit) return hit;
        const r = await buildReport(id);
        cache.set(id, r);
        return r;
      }
      ```
      
      - Cache the promise, not just the value, to collapse concurrent misses (stampede pattern, rules/03).
      - Invalidate explicitly on write paths or accept TTL staleness deliberately — "cache + forgot invalidation" bugs masquerade as data corruption.
      - HTTP layer first: `Cache-Control`/`ETag` on API responses and CDN caching beat in-process caches for read-heavy public data.
      
      ## Memory leaks
      
      Long-lived references are the leak; SPAs and Node servers never get the page-refresh absolution.
      
      Usual suspects:
      - **Listeners/observers on long-lived targets** (`window`, `document`, sockets, emitters) added per component/request and never removed → remove in cleanup, or `addEventListener(..., { signal })` + one `abort()` (rules/03). Disconnect `IntersectionObserver`/`MutationObserver`/`ResizeObserver`.
      - **Timers**: `setInterval` without `clearInterval` keeps its closure (and everything it captures) alive forever.
      - **Closures over large scopes**: a small callback capturing a huge parsed payload pins it; extract what you need into locals before creating the long-lived closure.
      - **Module-level caches without bounds**: `const cache = new Map()` growing per-key forever in a server = slow OOM. Bound it (LRU — `lru-cache`), or key by object with `WeakMap` so entries die with their keys.
      - **Detached DOM**: keeping element refs (in arrays, maps, closures) after removal from the document retains whole subtrees. Heap snapshot → search "Detached".
      - **Node-specific**: per-request data stuffed into module/global scope; `EventEmitter` listeners accumulating (MaxListenersExceededWarning is a leak smell, not a limit to raise blindly); unbounded in-flight maps without `finally` cleanup.
      
      ```ts
      // BAD — closure pins the whole 50MB parsed report for the life of the listener
      const report = await parseHugeReport(file);
      emitter.on('tick', () => updateBadge(report.summary.count));
      
      // GOOD — capture only the scalar
      const count = (await parseHugeReport(file)).summary.count;
      emitter.on('tick', () => updateBadge(count));
      ```
      
      Detection workflow (browser and Node `--inspect` alike):
      1. Heap snapshot → perform the suspected-leaking action 3-5× → snapshot again.
      2. Comparison view, sort by retained-size delta; look for arrays/maps/closures growing linearly with actions, and "Detached" DOM entries.
      3. Follow the retainer chain upward to the root holding the reference — that's the fix site, not the leaked object's class.
      4. Node services: export `process.memoryUsage().heapUsed`/RSS to metrics; alert on monotonic growth across hours; capture snapshots on signal (`v8.writeHeapSnapshot()` behind an admin endpoint) when it fires.
      
      ## Node event-loop blocking
      
      One blocked loop = every request stalled (full treatment in rules/04). Performance-audit angle:
      - Instrument: `perf_hooks.monitorEventLoopDelay()` histogram exported to metrics; alert p99 > 100ms.
      - Identify: clinic.js flame / `0x` flamegraphs under load; `blocked-at` in staging for stacks.
      - Fix order: cap input sizes → move CPU work to `piscina` workers → chunk unavoidable loops with `setImmediate` yields → cache the computation.
      - `JSON.stringify` of huge objects in logging/serialization is a stealth blocker — log IDs and summaries, not payload dumps.
      
      ```ts
      // Minimal loop-lag detector — cheap enough for production
      import { monitorEventLoopDelay } from 'node:perf_hooks';
      const h = monitorEventLoopDelay({ resolution: 20 });
      h.enable();
      setInterval(() => {
        metrics.gauge('event_loop_p99_ms', h.percentile(99) / 1e6);
        h.reset();
      }, 10_000).unref();
      ```
      
      ## Offloading and scheduling on the main thread
      
      - CPU work >50ms in the browser (parsing, diffing, search indexing, image manipulation): Web Worker (rules/03) — the main thread is for UI. Comlink removes the postMessage ceremony.
      - Truly-idle work (analytics aggregation, prefetch warmup): `requestIdleCallback` (with a timeout fallback) — never for anything user-visible.
      - Animation reads/writes: `requestAnimationFrame`; CSS transforms/opacity (compositor-only) over layout-triggering properties; `will-change` sparingly.
      - Chunked processing keeps input responsive: process N items → `await scheduler.yield()` → continue; combine with `AbortSignal` so navigation cancels the rest.
      
      ## Micro-level idioms that matter at scale only
      
      In hot paths (per-row in 100k iterations, per-frame): avoid spread-accumulator `reduce` (O(n²), rules/02), reuse compiled regexes, prefer `for...of` over chained array methods, avoid `try/catch`-free claims (modern engines made try cheap — don't contort code for it), and don't `delete obj.prop` in hot objects (deopts shapes; set to `undefined` or use Maps). Outside hot paths, write the readable version.
      
      ## Audit checklist
      
      - [ ] Bundle analyzer wired and a size budget enforced in CI (`size-limit`/bundlesize)? Absent on a frontend app = MEDIUM.
      - [ ] `grep -rn "from 'lodash'\|from \"lodash\"" src/` (non-`lodash-es`) and `from 'moment'` — heavy/cjs imports (MEDIUM). `grep -rn "import \* as" src/` — namespace imports of large libs.
      - [ ] `grep -rln "export \* from" src/` — barrel files on hot paths (LOW/MEDIUM).
      - [ ] `grep -rn "key={index}\|key={i}\|key={idx}" src/ --include="*.tsx"` — index keys on mutable lists (MEDIUM); `key={Math.random()` (HIGH — remount storm).
      - [ ] `grep -rn "useEffect" src/ --include="*.tsx" | wc -l` high relative to components → review for derived-state effects and fetch waterfalls (MEDIUM).
      - [ ] `grep -rn "useMemo\|useCallback\|React.memo" src/` — blanket memoization with unstable deps (inline objects in props of memoized components) = dead weight (LOW); missing memo on expensive list rows that profile hot (MEDIUM).
      - [ ] `grep -rn "'use client'" app/ src/` — at layout/page top level wholesale = RSC benefits discarded (MEDIUM in App Router projects).
      - [ ] Long lists: components mapping >hundreds of rows without virtualization (`grep -rn "\.map(" src/ --include="*.tsx"` + check data sizes) — MEDIUM.
      - [ ] `grep -rn "addEventListener" src/` without matching remove/`{ signal }`; `grep -rn "setInterval" src/` without `clearInterval` — leaks (MEDIUM).
      - [ ] `grep -rn "new Map()\|new Map<" src/` at module scope in server code — unbounded cache check (MEDIUM if grows per-request/per-user key).
      - [ ] `grep -rn "onScroll\|onMouseMove\|onResize\|addEventListener('scroll'" src/` — unthrottled continuous handlers doing layout reads (MEDIUM); could be Intersection/ResizeObserver.
      - [ ] Node: event-loop delay metric exported? `grep -rn "monitorEventLoopDelay" src/` — absent in a latency-sensitive service (LOW/MEDIUM).
      - [ ] `grep -rn "JSON.stringify" src/` in logging/hot request paths over large objects (MEDIUM).
      
    • 07-testing-and-tooling.md 14.6 KB
      # Testing & Tooling
      
      ## Test runner: vitest (apps), node:test (deps-free libs)
      
      Vitest (v4 current): native ESM/TS, vite-config reuse, watch mode, `projects` for multi-config repos (the old `workspace` option was removed in v4), jest-compatible API. Vitest 4 also stabilized Browser Mode (via `@vitest/browser-playwright` etc.) — real-browser component tests where jsdom fidelity isn't enough. Don't start new projects on jest (CJS-era transform pain). Pure libraries with zero build can use `node:test` and skip the dependency entirely.
      
      ```ts
      // vitest.config.ts
      import { defineConfig } from 'vitest/config';
      export default defineConfig({
        test: {
          environment: 'node',                    // 'jsdom'/'happy-dom' only for component tests (use `projects` to split)
          coverage: { provider: 'v8', thresholds: { lines: 80, branches: 75 } },
          restoreMocks: true,                     // auto-restore between tests — prevents cross-test mock bleed
          setupFiles: ['./test/setup.ts'],
        },
      });
      ```
      
      Practices:
      - Structure: Arrange-Act-Assert; one behavior per test; name as behavior (`'rejects expired tokens'`), not method names (`'test verifyToken'`).
      - Specific matchers: `toEqual` for deep structural, `toBe` for identity/primitives, `toMatchObject` for partial, `expect(fn).rejects.toThrow(SpecificError)` for async errors. Never `expect(await fn().catch(e => e)).toBeDefined()`-style mush.
      - Snapshot tests only for genuinely stable serialized output (CLI output, codegen); component snapshot sprawl is change-detector noise — assert specific things instead.
      - Fake time explicitly: `vi.useFakeTimers()` + `vi.setSystemTime()`; restore in `afterEach`. Flaky time/`Date.now` math in tests is a bug factory.
      - Test data via factories with overrides (`makeUser({ role: 'admin' })`), not 50-line fixture JSON copies.
      - `it.each` for input/output tables; property-based testing (`fast-check`) for parsers/serializers/invariants.
      - Mock module boundaries (`vi.mock`) sparingly — heavy module-mocking is a design smell; prefer injecting dependencies (function params, constructor args) so tests pass fakes naturally.
      
      ### Shape of the suite
      
      Honeycomb, not pyramid, for typical web services: most value sits in integration-level tests (route handler → real validation → in-memory/testcontainer DB → response). Pure-unit-test what is genuinely algorithmic (parsers, pricing, reducers); E2E only the money paths. Signs of an inverted suite: hundreds of tests mocking every collaborator, green CI, production bugs in the seams.
      
      ```ts
      // Integration over a real boundary — fastify.inject hits routing, schema, handler, serializer
      const res = await app.inject({ method: 'POST', url: '/orders', payload: { sku: 'A1', qty: 2 } });
      expect(res.statusCode).toBe(201);
      expect(OrderSchema.parse(res.json())).toMatchObject({ sku: 'A1' });
      ```
      
      - Database tests: testcontainers (real Postgres in Docker) over fragile mocks of the query builder; transaction-per-test rollback for speed.
      - Determinism rules: no real network (MSW errors on it), no real time (fake timers), no shared mutable fixtures, no test-order dependence (`vitest --sequence.shuffle` in CI surfaces it).
      - A flaky test is a P1 on the suite: quarantine immediately, fix or delete within days — tolerated flake trains the team to ignore red.
      
      ## Testing-library: test behavior, not implementation
      
      Tests should survive refactors that don't change behavior. Query like a user, assert what the user sees.
      
      ```tsx
      // BAD — implementation-coupled: breaks on rename/restructure, passes when a11y is broken
      const { container } = render(<Login />);
      fireEvent.click(container.querySelector('.submit-btn')!);
      expect(setStateSpy).toHaveBeenCalledWith({ loading: true });
      
      // GOOD — role-based queries + userEvent + visible outcome
      const user = userEvent.setup();
      render(<Login />);
      await user.type(screen.getByLabelText(/email/i), 'a@b.co');
      await user.click(screen.getByRole('button', { name: /sign in/i }));
      expect(await screen.findByRole('alert')).toHaveTextContent(/invalid credentials/i);
      ```
      
      - Query priority: `getByRole` > `getByLabelText` > `getByPlaceholderText` > `getByText` > `getByTestId` (last resort). Unreachable-by-role often means inaccessible markup — fix the component.
      - `userEvent` over `fireEvent` (simulates real event sequences: focus, keydown, input).
      - Async: `await screen.findBy...` / `waitFor` for assertions; never arbitrary `setTimeout` sleeps. Don't `waitFor` with side effects inside the callback.
      - Don't assert internal state, spy on setState, or shallow-render. Don't test "renders without crashing" only.
      - The same philosophy applies server-side: test handlers via HTTP (`fastify.inject`, supertest) against responses, not by spying on internals.
      
      ## Network: MSW
      
      Mock at the network boundary, not the fetch wrapper — tests then exercise your real client code (serialization, error handling, retries).
      
      ```ts
      // test/handlers.ts
      import { http, HttpResponse } from 'msw';
      export const handlers = [
        http.get('/api/users/:id', ({ params }) =>
          HttpResponse.json({ id: params.id, email: 'a@b.co' })),
      ];
      
      // test/setup.ts
      import { setupServer } from 'msw/node';
      export const server = setupServer(...handlers);
      beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));   // fail on unmocked calls
      afterEach(() => server.resetHandlers());
      afterAll(() => server.close());
      
      // per-test override for error paths
      server.use(http.get('/api/users/:id', () => HttpResponse.json({}, { status: 500 })));
      ```
      
      - `onUnhandledRequest: 'error'` — silent passthrough hides missing mocks and accidental real network in CI.
      - Same handlers reusable in the browser (`setupWorker`) for Storybook/dev.
      - Don't mock your own modules to avoid network (`vi.mock('./api')`) — that skips the very code most likely to be wrong.
      
      ## E2E: Playwright
      
      Unit/integration tests for logic breadth; a thin Playwright layer for critical user journeys (signup, checkout, the money paths) — not a port of every unit case.
      
      - Web-first assertions auto-retry: `await expect(page.getByRole('button')).toBeEnabled()` — never `page.waitForTimeout` (flake generator, grep-able finding).
      - Same locator philosophy as testing-library: `getByRole`/`getByLabel` over CSS/XPath.
      - Isolate state: storageState fixture per auth role (login once via API, reuse); each test owns its data; no inter-test order dependence.
      - `trace: 'on-first-retry'` for CI debugging; run against production builds; shard in CI.
      - API-seed test data; don't drive setup through the UI.
      
      ## ESLint flat config + typescript-eslint strict
      
      Flat config (`eslint.config.js`) is the only supported format since ESLint 9; ESLint 10 (Feb 2026) removes the eslintrc system entirely, resolves config from each linted file's directory (multiple configs per run — monorepo-friendly), and requires Node ≥20.19. typescript-eslint v8 supports ESLint 9 and 10. Type-aware strict preset catches real bugs (floating promises, unsafe any-flow) that syntax-only linting can't.
      
      ```js
      // eslint.config.js
      import eslint from '@eslint/js';
      import tseslint from 'typescript-eslint';
      
      export default tseslint.config(
        eslint.configs.recommended,
        ...tseslint.configs.strictTypeChecked,     // not just "recommended"
        ...tseslint.configs.stylisticTypeChecked,
        {
          languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
          rules: {
            '@typescript-eslint/no-floating-promises': 'error',
            '@typescript-eslint/no-misused-promises': 'error',
            '@typescript-eslint/switch-exhaustiveness-check': 'error',
            '@typescript-eslint/consistent-type-imports': 'error',
            'eqeqeq': ['error', 'always'],
            'no-restricted-syntax': ['error',
              { selector: "CallExpression[callee.name='eval']", message: 'eval is banned' }],
          },
        },
        { files: ['**/*.test.ts'], rules: { '@typescript-eslint/no-unsafe-assignment': 'off' } },
      );
      ```
      
      - `projectService: true` (replaces `project: true` boilerplate) enables type-aware rules with good perf.
      - Formatting belongs to Prettier (or Biome): no stylistic-format ESLint rules fighting the formatter. Biome is a fast single-tool alternative when its rule coverage suffices — but typescript-eslint's type-aware rules have no Biome equivalent yet; security-sensitive repos keep typescript-eslint.
      - Useful plugins: `eslint-plugin-regexp` (ReDoS), `eslint-plugin-react-hooks` (v6+: flat-config presets, React-Compiler-powered rules — `recommended-latest` to opt in), `eslint-plugin-jsx-a11y`, `eslint-plugin-import-x` (cycles: `import-x/no-cycle`).
      - Downgrading errors to warnings "to get CI green" creates a permanent warning swamp — fix or explicitly disable per-line with a reason comment.
      
      ## Formatting and pre-commit
      
      - One formatter, zero debate: Prettier (default) or Biome format (faster, one tool with its linter). Config committed; editor format-on-save; CI checks `--check`. No ESLint formatting rules alongside (`eslint-config-prettier` if any legacy stylistic rules linger).
      - Pre-commit via lefthook/husky + lint-staged: format + eslint --fix on staged files only. Keep hooks <5s — slow hooks get `--no-verify`'d into irrelevance; typecheck and tests belong in CI, not pre-commit.
      
      ```yaml
      # lefthook.yml
      pre-commit:
        parallel: true
        commands:
          lint: { glob: '*.{ts,tsx}', run: 'eslint --fix {staged_files} && git add {staged_files}' }
          format: { glob: '*.{ts,tsx,json,md}', run: 'prettier --write {staged_files} && git add {staged_files}' }
      ```
      
      - TS build perf in CI: `tsc -b --incremental` with restored `.tsbuildinfo` cache; in monorepos, run typecheck per-package via turborepo/nx so only affected packages pay.
      
      ## Dead code: knip
      
      Knip finds unused files, exports, types, and dependencies — the stuff `tsc` can't see because exported-but-never-imported is still "used" to the compiler.
      
      ```jsonc
      // knip.json
      { "entry": ["src/index.ts", "src/cli.ts"], "project": ["src/**/*.ts"] }
      ```
      
      - Run in CI (`knip --reporter compact`); triage with `--include dependencies` first (unused deps = supply-chain surface, rules/05), then files, then exports.
      - Pairs with `tsc --noUnusedLocals --noUnusedParameters` (intra-file) — knip covers inter-file.
      - Knip's output is a **candidate list, not a finding**: a package loaded dynamically (string `require`, plugin manifest, DI by convention) reads as unused, and a package whose symbol is referenced only on an unreachable branch reads as used. Prove each candidate by deleting it in a scratch copy and running the real build + suite — `sota-devsecops` rules/10.
      - Unused exports kill tree-shaking analysis precision and mislead readers; deleting code is a feature.
      
      ## Library publishing: publint + arethetypeswrong
      
      Broken `exports`/types maps are the top npm-library bug class (works in dev, breaks in consumers' bundlers or `NodeNext` resolution).
      
      ```jsonc
      // package.json for an ESM-first library
      {
        "type": "module",
        "exports": {
          ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
        },
        "files": ["dist"],
        "sideEffects": false
      }
      ```
      
      - `npx publint` — validates packaging (exports map, file presence, ESM/CJS field correctness).
      - `npx @arethetypeswrong/cli --pack` — validates types resolve under every resolution mode (node16 ESM/CJS, bundler); catches "false ESM" and masquerading-CJS d.ts.
      - Both run in CI before publish. `files` allowlist prevents leaking `.env`/configs into tarballs.
      - Dual ESM/CJS only if consumers demand it (tsup/unbuild make it tolerable); otherwise ESM-only and say so in the README.
      
      ## Type-level testing
      
      Libraries and complex generics deserve type tests — types are API surface and they regress silently.
      
      ```ts
      import { expectTypeOf } from 'vitest';
      test('parse narrows to branded id', () => {
        expectTypeOf(parseUserId('x')).toEqualTypeOf<UserId>();
        // @ts-expect-error — plain string must not be assignable
        const _: UserId = 'raw';
      });
      ```
      
      `vitest --typecheck` runs these; `@ts-expect-error` lines double as negative type assertions. For published libraries, snapshot the public API surface with `api-extractor` or `tsd` so breaking type changes show up in review.
      
      ## CI gates (the minimum bar)
      
      Order fast→slow, all blocking: `tsc --noEmit` → eslint → unit/integration (vitest, coverage thresholds) → knip → build → playwright smoke. Plus `npm audit`/osv-scan and lockfile-frozen install (rules/05). A repo where `tsc --noEmit` isn't in CI will accumulate `as any` until types are decorative.
      
      ```yaml
      # .github/workflows/ci.yml — minimal blocking pipeline
      steps:
        - uses: actions/checkout@<pinned-sha>
        - uses: actions/setup-node@<pinned-sha>
          with: { node-version-file: '.nvmrc', cache: 'pnpm' }
        - run: pnpm install --frozen-lockfile
        - run: pnpm tsc --noEmit
        - run: pnpm eslint . --max-warnings 0
        - run: pnpm vitest run --coverage
        - run: pnpm knip
        - run: pnpm build
      ```
      
      `--max-warnings 0` keeps warnings from becoming wallpaper. Cache pnpm store, not node_modules. Pin action SHAs (rules/05).
      
      ## Audit checklist
      
      - [ ] CI runs typecheck, lint, tests as blocking steps — `cat .github/workflows/*.yml | grep -E "tsc|eslint|vitest|test"`; missing typecheck gate = MEDIUM.
      - [ ] ESLint config is flat + `strictTypeChecked` (type-aware): `grep -rn "strictTypeChecked\|projectService" eslint.config.*` — syntax-only linting in a TS repo = MEDIUM.
      - [ ] `grep -rn "eslint-disable" src/ | grep -v -- "--"` — disables without reason comments; count trend (LOW each, MEDIUM in volume).
      - [ ] `grep -rn "querySelector\|container\." src/**/*.test.tsx` and `getByTestId` density — implementation-coupled tests (LOW/MEDIUM).
      - [ ] `grep -rn "fireEvent" src/` in component tests — should be `userEvent` (LOW).
      - [ ] `grep -rn "waitForTimeout\|setTimeout" e2e/ tests/` — sleep-based waits = flake (MEDIUM).
      - [ ] `grep -rn "vi.mock(\|jest.mock(" src/ | wc -l` high vs test count — module-mock-heavy suite; check MSW present for network (`grep -rn "msw" package.json`) (MEDIUM if fetch wrappers are mocked instead).
      - [ ] MSW server with `onUnhandledRequest: 'error'`? Real network calls in unit tests (`grep -rn "localhost\|https://" src/**/*.test.ts`) = MEDIUM (flaky + slow).
      - [ ] Coverage thresholds configured and honest (no `**/index.ts` exclusion games) — absent = LOW; tests asserting nothing (`grep -rn "expect(" -L` on test files) = HIGH for the affected area.
      - [ ] knip (or equivalent) in CI? Run `npx knip` during audit — large unused-dependency list = MEDIUM (supply-chain surface).
      - [ ] Libraries: `npx publint && npx @arethetypeswrong/cli --pack` clean; `files` allowlist present — failures = HIGH for published packages.
      - [ ] Snapshot test sprawl: `grep -rln "toMatchSnapshot" src/ | wc -l` — high count = change-detector suite (LOW/MEDIUM).
      - [ ] Test factories vs giant fixtures; fake timers restored (`restoreMocks: true` or explicit afterEach) — mock bleed causes order-dependent flake (MEDIUM).
      
  • SKILL.md 8 KB
    ---
    name: sota-javascript-typescript
    description: State-of-the-art JavaScript and TypeScript engineering (2026) for both writing and auditing code. Covers strict TypeScript configuration and type design, language idioms and pitfalls, async patterns, Node.js backends, JS/TS-specific security (XSS, prototype pollution, supply chain, injection), frontend/React and Node performance, and testing/tooling. Use whenever building, reviewing, refactoring, or security-auditing code involving JavaScript, TypeScript, Node, npm, React, frontend code, tsconfig, package.json, vitest, or any .ts/.tsx/.js/.mjs files.
    ---
    
    # SOTA JavaScript / TypeScript Engineering
    
    ## Purpose
    
    This skill encodes 2026 state-of-the-art for JS/TS so generated code is strict, secure, and fast by default — and so audits of existing code find the bug classes that actually bite: untyped boundaries, floating promises, XSS sinks, prototype pollution, supply-chain gaps, event-loop blocking, and leak-prone listeners. It has two operating modes; pick one explicitly at the start of a task.
    
    Baseline assumptions (mid-2026): TypeScript ≥5.9 strict (6.0 = last JS-based compiler; 7.0 Go-native stable since July 2026, shipped as the regular `typescript` package — frameworks embedding the compiler API via Volar, e.g. Vue/Angular/Astro/Svelte, stay on 6.0 until a stable plugin API lands), ESM-first, Node LTS ≥22 (24 = active LTS; Node 26 ships Temporal by default), ES2024+ available, React 19.2-era with Server Components and React Compiler 1.0 where relevant, vitest 4 + flat-config ESLint (v9/v10).
    
    ## BUILD mode (writing or modifying code)
    
    1. **Read the relevant rules files first** (index below) for the area you're touching. Don't generate from memory what a rules file specifies.
    2. **Defaults unless the codebase dictates otherwise**: strict tsconfig (rules/01), ESM, `unknown` over `any`, discriminated unions for state, zod/valibot parse at every untrusted boundary, `??`/`?.` discipline, AbortController on cancellable ops, pino logging in services, Web APIs over deps.
    3. **Match the host codebase** for style, framework, and structure — but do not replicate its security bugs or `any`-sprawl into new code. New code meets the bar even in old repos.
    4. **Boundary rule**: every input from outside the type system (HTTP, env, JSON.parse, storage, postMessage, DB without typed client) is parsed with a schema before use. No `as T` on external data.
    5. **Finish the job**: new code compiles under `tsc --noEmit`, passes lint, and ships with behavior-level tests (rules/07). Handle the error path of every async call — no floating promises.
    6. When a requirement conflicts with a rule (e.g., legacy CJS, jest), follow the codebase and note the deviation; don't silently half-apply both.
    
    ## AUDIT mode (reviewing existing code)
    
    Scope first (frontend? Node service? library?), then read the matching rules files and run their audit checklists — each ends with grep/eslint hunt patterns. Validate findings: confirm attacker-controlled data actually reaches the sink, confirm the perf issue is on a hot path. No speculative findings.
    
    **Severity conventions:**
    - **CRITICAL** — remotely exploitable now: untrusted input reaching eval/innerHTML/exec/SQL, auth bypass, secrets exfiltratable via XSS.
    - **HIGH** — exploitable with conditions, or guaranteed-corruption bug class: missing boundary validation, prototype-pollution-prone merge of request data, floating promises dropping errors, tokens in localStorage, unbounded request bodies, float money math, sync crypto blocking the loop.
    - **MEDIUM** — weakened posture or latent defect: missing strict tsconfig flags, no graceful shutdown, missing CSP/timeouts, `||` vs `??` on falsy-valid values, index keys on mutable lists, unbounded caches, missing supply-chain controls.
    - **LOW** — hygiene/debt: dead deps, `hasOwnProperty`, console.log in services, snapshot sprawl, missing memoization on profiled-hot paths.
    
    **Finding format:**
    ```
    [SEVERITY] Title (CWE-xxx if security)
    File: path/to/file.ts:42
    Issue: what is wrong, in one or two sentences
    Evidence: the offending snippet
    Impact: what an attacker/user/operator experiences
    Fix: concrete change (code if short)
    ```
    
    Order the report CRITICAL→LOW, deduplicate repeated patterns into one finding with a file list, and end with the top 3 systemic recommendations (e.g., "enable noUncheckedIndexedAccess", "adopt MSW", "add zod to route boundaries").
    
    ## Rules index
    
    | File | Read this when... |
    |---|---|
    | [rules/01-typescript-config-and-types.md](rules/01-typescript-config-and-types.md) | touching tsconfig; designing types/interfaces; seeing `any`/casts; modeling state; validating input shape; setting up a library or monorepo; deciding zod-vs-types questions |
    | [rules/02-language-idioms.md](rules/02-language-idioms.md) | writing any JS/TS logic: equality, `??`/`?.`, array methods, immutability, Map/Set, **in-band sentinels (absence encoded as `-1`/`0`/`""`)** — **`-1` lies where `NaN` poisons**, error classes and Result types, generators, dates (Temporal), money/number precision |
    | [rules/03-async-patterns.md](rules/03-async-patterns.md) | anything with promises/async: combinator choice, floating promises, AbortController/timeouts, event-loop ordering, top-level await, workers, streams, async race conditions |
    | [rules/04-node-backend.md](rules/04-node-backend.md) | building/auditing Node services: runtime choice, dropping deps for built-ins, env config, HTTP hardening (timeouts/body limits), graceful shutdown, process error policy, pino |
    | [rules/05-security.md](rules/05-security.md) | any security-relevant code or audit: XSS sinks, CSP, prototype pollution, npm supply chain, ReDoS, token storage/JWT, postMessage, child_process injection, SSRF |
    | [rules/06-performance.md](rules/06-performance.md) | bundle size, React re-renders/keys/RSC, virtualization, debounce/throttle, memory leaks, Node event-loop blocking, profiling before optimizing |
    | [rules/07-testing-and-tooling.md](rules/07-testing-and-tooling.md) | writing tests or setting up tooling: vitest, testing-library behavior testing, MSW, Playwright, ESLint flat + typescript-eslint strict, knip, publint/attw, CI gates. **Test *strategy* — suite shape, TDD, doubles, test data, flake policy — lives in `sota-testing`; load it for any build that writes logic. This file owns JS/TS runner mechanics only.** |
    
    For a full audit, read 01→07 in order; security-focused audits prioritize 05, 01, 03, 04.
    
    ## Top 10 non-negotiables
    
    1. **`strict: true` + `noUncheckedIndexedAccess`** in every tsconfig; no `any`, no `@ts-ignore` — `unknown` + narrowing, `@ts-expect-error` with reason.
    2. **Parse, don't cast, at boundaries**: zod/valibot on every HTTP body/query, env var, JSON.parse, webhook, postMessage payload. Types inside, schemas at the edge.
    3. **No floating promises**: every promise awaited or explicitly `.catch`-handled; `@typescript-eslint/no-floating-promises` as error. `allSettled` for independent work; bounded concurrency.
    4. **Discriminated unions for state**, exhaustive `switch` with `never` default — no boolean-soup interfaces with optional data/error pairs.
    5. **`===` always; `??`/`?.` over `||`/`&&`** for null-handling; immutable updates (`toSorted`, `structuredClone`, spread) on shared data.
    6. **Errors are `Error` subclasses with `cause`**; never throw strings; never swallow with empty catch; Node policy = log fatally then exit on uncaught.
    7. **XSS sinks are forbidden by default**: no `innerHTML`/`dangerouslySetInnerHTML` with non-constant input unless DOMPurify-sanitized at render; no eval family ever; CSP on HTML responses.
    8. **No secrets in localStorage; no shell interpolation**: httpOnly cookies for tokens; `execFile`/`spawn` array-args, never `exec` with template strings; parameterized SQL.
    9. **Supply chain controlled**: committed lockfile + `npm ci`, install scripts disabled/allowlisted, update cooldown, minimal deps — prefer platform built-ins (fetch, node:test, crypto.randomUUID).
    10. **AbortController + timeouts on all I/O**; never block the event loop (>50ms CPU → worker; no `*Sync` in request paths); listeners and intervals always cleaned up.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related