diff --git a/.lore.md b/.lore.md index 43fdff677..a160a005b 100644 --- a/.lore.md +++ b/.lore.md @@ -7,35 +7,35 @@ * **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth token precedence in \`src/lib/db/auth.ts\`: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. \`runInteractiveLogin\` catches OAuth flow errors internally and returns falsy on failure; login command sets \`process.exitCode = 1\` and returns normally (does NOT reject). Tests expecting \`rejects.toThrow()\` will fail — assert via fetch-call inspection instead. \`requestDeviceCode\` requires \`SENTRY\_CLIENT\_ID\` env var. + +* **Binary build pipeline: esbuild → fossilize → Node SEA (replacing Bun.build compile)**: (architecture) Binary build pipeline: \`src/bin.ts → \[esbuild CJS, node22 target] → dist-build/bin.js → \[fossilize --no-bundle] → Node SEA binary → \[binpunch] → gzip\`. Keep inputs in \`dist-build/\` (separate from fossilize's \`--out-dir dist-bin/\` — fossilize always \`rm -rf dist-bin/\` on start). Single fossilize invocation for all platforms via \`FOSSILIZE\_PLATFORMS\`. Post-process: rename \`sentry-win-x64.exe\`→\`sentry-windows-x64.exe\`. Platform matrix: darwin-arm64, darwin-x64, linux-x64, linux-arm64, win32-x64 (no musl). Ink sidecar: \`--assets dist-build/ink-app.js\`. \`NODE\_VERSION=22\` pinned. \`FOSSILIZE\_SIGN=y\` on push to main/release. esbuild: \`format:'cjs'\` + \`target:'node22'\` + inject \`import-meta-url.js\` shim. Build script: \`pnpm tsx script/build.ts\`. Gzip only when \`RELEASE\_BUILD=1\`. binpunch always runs. Sourcemap uploaded to Sentry, never shipped. + -* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: (architecture) Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr. Telemetry opt-out priority: (1) \`SENTRY\_CLI\_NO\_TELEMETRY=1\`, (2) \`DO\_NOT\_TRACK=1\`, (3) \`metadata.defaults.telemetry\`, (4) default on. Shell completions set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` in \`bin.ts\` before imports. Timing queued to \`completion\_telemetry\_queue\` SQLite table; normal runs drain via \`DELETE ... RETURNING\`. \`ENV\_VAR\_REGISTRY\` in \`src/lib/env-registry.ts\` is the single source for all honored env vars; \`topLevel: true\` + \`briefDescription\` surfaces in \`--help\`. Add new env vars with \`installOnly: true\` if install-script-only. +* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr. Telemetry opt-out priority: (1) \`SENTRY\_CLI\_NO\_TELEMETRY=1\`, (2) \`DO\_NOT\_TRACK=1\`, (3) \`metadata.defaults.telemetry\`, (4) default on. Shell completions set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` in \`bin.ts\` before imports. Timing queued to \`completion\_telemetry\_queue\` SQLite table; normal runs drain via \`DELETE ... RETURNING\`. \`ENV\_VAR\_REGISTRY\` in \`src/lib/env-registry.ts\` is single source for all honored env vars; \`topLevel: true\` + \`briefDescription\` surfaces in \`--help\`. Add install-script-only vars with \`installOnly: true\`. * **DSN cache invalidation uses two-level mtime tracking (sourceMtimes + dirMtimes)**: DSN cache invalidation — two-level mtime tracking: \`sourceMtimes\` (DSN-bearing files, catches in-place edits) + \`dirMtimes\` (every walked dir, catches new files) + root mtime fast-path + 24h TTL. Dropping either map is a correctness regression. Walker emits mtimes via \`onDirectoryVisit\` hook + \`recordMtimes\` option; DSN scanner uses \`grepFiles({pattern: DSN\_PATTERN, recordMtimes: true, onDirectoryVisit})\`. \`scanCodeForFirstDsn\` stays on direct walker loop (worker init ~20ms dominates). Invariants: \`processMatch\` must record mtime for EVERY file with host-validated DSN via \`fileHadValidDsn\` flag independent of \`seen.has(raw)\`. \`scanDirectory\` catch MUST return empty \`dirMtimes: {}\`, NOT partial map; \`ConfigError\` re-throws. - -* **Gateway auth: per-session registry + global fallback in auth.ts**: (architecture) \`packages/gateway/src/auth.ts\`: \`AuthCredential\` (api-key|bearer). Two-level auth lookup: \`sessionAuth\` Map (per-session) → \`lastSeenAuth\` global fallback via \`resolveAuth(sessionID?)\`. \`authFingerprint()\` = SHA-256 truncated to 16 hex chars. \`setSessionAuth\`/\`getSessionAuth\`/\`deleteSessionAuth\` manage session scope; \`setLastSeenAuth\`/\`getLastSeenAuth\` manage global fallback. - - -* **Grep worker pool: binary-transferable matches + streaming dispatch in src/lib/scan/**: Grep worker pool (\`src/lib/scan/worker-pool.ts\` + \`grep-worker.js\`): lazy singleton, size \`min(8, max(2, availableParallelism()))\`. Matches encoded as \`Uint32Array\` quads \`\[pathIdx, lineNum, lineOffset, lineLength]\` + \`linePool\` string, transferred via \`postMessage(msg, \[ints.buffer])\` (~40% faster than structuredClone). Worker imported via \`with { type: 'text' }\` → \`Blob\` + \`URL.createObjectURL\`; \`new Worker(new URL(...))\` HANGS in \`bun build --compile\` binaries. FIFO \`pending\` queue per worker — per-dispatch \`addEventListener\` causes wrong-request resolution. \`ref()\`/\`unref()\` idempotent booleans, NOT refcounted — only unref when \`inflight\` drops to 0; spawn unref'd. Disable via \`SENTRY\_SCAN\_DISABLE\_WORKERS=1\`. Track dispatched/failed batches with \`Promise.allSettled\`; throw if all failed so DSN cache doesn't persist false-negatives. - * **Host-scoped token model: auth.host column + three-layer enforcement**: Host-scoped token model (schema v16): every token bound to issuing host via \`auth.host\` column, lazy-migrated from boot-env. Trust established ONLY via \`sentry auth login --url\` or shell-exported \`SENTRY\_HOST\`/\`SENTRY\_URL\` at boot — \`.sentryclirc\` URL never a trust source. Three enforcement layers: (1) \`applySentryUrlContext\` throws on URL-arg mismatch; (2) \`applySentryCliRcEnvShim\` throws on rc-url mismatch (auth login/logout bypass via \`skipUrlTrustCheck\`); (3) fetch-layer \`isRequestOriginTrusted\`. Region trust: in-process Set in \`db/regions.ts\`, auto-synced by \`setOrgRegion(s)\`. \`clearTrustedHostState\` must NOT clear login anchor (breaks IAP re-auth). \`HostScopeError\` has overloads \`(message)\` and \`(source, destinationUrl, tokenHost)\`. Test helpers: \`resetHostScopingState()\` bundles \`resetEnvTokenHostForTesting\` + \`resetLoginTrustAnchorForTesting\` + \`resetTrustedRegionUrlsForTesting\`. E2E: pass \`--url ${ctx.serverUrl}\` to \`auth login --token\`; \`SENTRY\_URL\` alone doesn't anchor. Multi-region tests need \`registerTrustedRegionUrls\`. * **isSentrySaasUrl vs isSaaSTrustOrigin: two intentional SaaS checks**: \`src/lib/sentry-urls.ts\` exports two SaaS-detection helpers with intentional split: (1) \`isSentrySaasUrl(url)\` — hostname-only check (\`sentry.io\` or \`\*.sentry.io\`), accepts any protocol/port. Used for routing/UX: custom-headers warning, \`getSentryBaseUrl\`/\`isSelfHosted\`, region resolution skip, telemetry \`is\_self\_hosted\` tag. (2) \`isSaaSTrustOrigin(url)\` — stricter: additionally requires \`https:\` and default port. Used for security decisions: token-host trust comparison, sentryclirc URL trust check, URL-arg trust, login refusal. Rule: hostname-only for routing/UX (don't break users behind TLS-terminating proxies with \`http://sentry.io\`); strict for credential scoping. JSDoc on \`isSentrySaasUrl\` points callers to \`isSaaSTrustOrigin\` for security contexts. Keep both implementations in sync re: hostname matching. + +* **Node SEA ink sidecar: node:sea.getAsset() replaces Bun /$bunfs/ virtual FS**: (architecture) Node SEA ink sidecar: \`node:sea.getAsset()\` replaces Bun \`/$bunfs/\` virtual FS. Ink UI sidecar embedded via \`fossilize --assets dist-build/ink-app.js\`; asset key = raw CLI arg. At runtime: \`sea.getRawAsset('dist-build/ink-app.js')\`. Main bundle never calls \`import('ink')\` — sidecar pre-bundled by text-import-plugin. Dual-mode: detect SEA via \`createRequire(import.meta.url)('node:sea')\` with try/catch fallback. \`useSnapshot: true\` BROKEN. \`useCodeCache: true\` ~15% startup improvement but platform-specific V8 blob. Suppress \`ExperimentalWarning: SQLite\`: \`process.on('warning', ...)\` at very top of \`src/bin.ts\` BEFORE any imports. fossilize asset manifest key = \`basename(manifestPath)\`; entry keys = \`entry.file\`. \`new Worker(new URL(...))\` HANGS in SEA — use Blob+URL.createObjectURL. + -* **safe-read.ts wraps isRegularFile + Bun.file().text() for FIFO-safe user-path reads**: (architecture) \`src/lib/safe-read.ts\` \`safeReadFile(path, operation)\` combines \`isRegularFile()\` + file read + broad error swallow (FIFO/ENOENT/EACCES/EPERM/EISDIR/ENOTDIR). \*\*Do NOT use for committed config loads\*\* — swallows EPERM/EISDIR, making \`chmod 000 .sentryclirc\` manifest as confusing 'no auth token'. For loud permission surfacing, call \`fs.promises.stat\` directly, gate on \`isFile()\`, catch only ENOENT/EACCES. \*\*General rule\*\*: bare \`catch {}\` swallows \`EACCES\`/\`EPERM\`/\`EIO\` — always check \`(err as NodeJS.ErrnoException).code === 'ENOENT'\` and re-throw anything else. \`read-files.ts\`/\`workflow-inputs.ts\` use direct stat to reuse one stat for size-gating. Test with real \`mkfifo\` + short timeout as hang detector. +* **safe-read.ts wraps isRegularFile + Bun.file().text() for FIFO-safe user-path reads**: \`src/lib/safe-read.ts\` \`safeReadFile(path, operation)\` combines \`isRegularFile()\` + file read + broad error swallow (FIFO/ENOENT/EACCES/EPERM/EISDIR/ENOTDIR). Do NOT use for committed config loads — swallows EPERM/EISDIR, making \`chmod 000 .sentryclirc\` manifest as confusing 'no auth token'. For loud permission surfacing, call \`fs.promises.stat\` directly, gate on \`isFile()\`, catch only ENOENT/EACCES. General rule: bare \`catch {}\` swallows \`EACCES\`/\`EPERM\`/\`EIO\` — always check \`(err as NodeJS.ErrnoException).code === 'ENOENT'\` and re-throw anything else. \`read-files.ts\`/\`workflow-inputs.ts\` use direct stat to reuse one stat for size-gating. Test with real \`mkfifo\` + short timeout as hang detector. * **Seer trial prompt uses middleware layering in bin.ts error handling chain**: Seer trial prompt via error middleware layering: \`bin.ts\` chain is \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (\`no\_budget\`/\`not\_enabled\`) caught by inner wrapper; auth errors bubble to outer. Trial API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start: \`PUT /api/0/customers/{org}/product-trial/\`. SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` excluded. \`startSeerTrial\` accepts \`category\` from trial object — don't hardcode. -* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API quirks: (1) Events need org+project (\`/projects/{org}/{project}/events/{id}/\`); issues use legacy global \`/api/0/issues/{id}/\`; traces need org only. (2) \`/users/me/\` returns 403 for OAuth — use \`/auth/\` instead via \`getControlSiloUrl()\`. (3) Chunk upload endpoint returns camelCase (\`chunkSize\`, etc.) — exception to snake\_case convention. (4) 204/205 responses throw \`ApiError\` not \`TypeError\` from \`rawApiRequest\`. (5) Magic @ selectors: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\` detected before \`validateResourceId\`. \`SELECTOR\_MAP\` case-insensitive; \`resolveSelector\` calls \`listIssuesPaginated\` with \`perPage: 1\`. Supports org-prefixed: \`sentry/@latest\`. (6) \`issue resolve --in\` grammar: omitted→immediate, \`\\`→\`inRelease\`, \`@next\`→\`inNextRelease\`, \`@commit\`→auto-detect git HEAD. \`parseResolveSpec\` splits on LAST \`@\` for scoped names. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. +* **Sentry API: events require org+project, issues have legacy global endpoint**: (architecture) Sentry API quirks: (1) Events need org+project (\`/projects/{org}/{project}/events/{id}/\`); issues use legacy global \`/api/0/issues/{id}/\`; traces need org only. (2) \`/users/me/\` returns 403 for OAuth — use \`/auth/\` via \`getControlSiloUrl()\`. (3) Chunk upload endpoint returns camelCase (\`chunkSize\`) — exception to snake\_case. (4) 204/205 responses throw \`ApiError\` not \`TypeError\` from \`rawApiRequest\`. (5) Magic \`@\` selectors: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\` detected before \`validateResourceId\`; \`SELECTOR\_MAP\` case-insensitive; \`resolveSelector\` calls \`listIssuesPaginated\` with \`perPage: 1\`; supports org-prefixed \`sentry/@latest\`. (6) \`issue resolve --in\` grammar: omitted→immediate, \`\\`→\`inRelease\`, \`@next\`→\`inNextRelease\`, \`@commit\`→auto-detect git HEAD. \`parseResolveSpec\` splits on LAST \`@\`. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. -* **Sentry CLI authenticated fetch architecture with response caching**: Authenticated fetch + response cache: \`createAuthenticatedFetch\`: auth headers, 30s timeout, max 2 retries, 401 refresh, span tracing. \`buildAttemptFactory\` clones \`Request\`; do NOT materialize FormData (strips boundary). Per-endpoint timeout overrides (e.g. \`/autofix/\` 120s). Response cache RFC 7234 at \`~/.sentry/cache/responses/\`, GET 2xx only. TTL tiers: stable=5min, volatile=60s, immutable=24h. \`@sentry/api\` SDK passes Request with no init — undefined init → empty headers stripping Content-Type (HTTP 415); fall back to \`input.headers\` when init undefined. Guard \`Array.isArray(data)\` before \`.map()\` (SDK returns \`{}\` for 204/empty). GET response cache checked BEFORE fetch — tests asserting call counts see 0 calls if prior test cached same URL. Tests mocking fetch MUST call \`useTestConfigDir()\` + \`setAuthToken()\` + \`resetCacheState()\` + \`disableResponseCache()\` + \`resetAuthenticatedFetch()\` in beforeEach. +* **Sentry CLI authenticated fetch architecture with response caching**: Authenticated fetch + response cache: \`createAuthenticatedFetch\`: auth headers, 30s timeout, max 2 retries, 401 refresh, span tracing. \`buildAttemptFactory\` clones \`Request\`; do NOT materialize FormData (strips boundary). Per-endpoint timeout overrides (e.g. \`/autofix/\` 120s). Response cache RFC 7234 at \`~/.sentry/cache/responses/\`, GET 2xx only. TTL tiers: stable=5min, volatile=60s, immutable=24h. \`@sentry/api\` SDK passes Request with no init — undefined init → empty headers stripping Content-Type (HTTP 415); fall back to \`input.headers\` when init undefined. Guard \`Array.isArray(data)\` before \`.map()\` (SDK returns \`{}\` for 204/empty). Tests mocking fetch MUST call \`useTestConfigDir()\` + \`setAuthToken()\` + \`resetCacheState()\` + \`disableResponseCache()\` + \`resetAuthenticatedFetch()\` in beforeEach — GET response cache checked BEFORE fetch, so prior test cache hits produce 0 calls. * **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: Resolve-target cascade: (1) CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite defaults, (4) DSN auto-detection, (5) directory name inference. SENTRY\_PROJECT supports \`org/project\` combo — SENTRY\_ORG ignored if set. Schema v13 merged \`defaults\` table into \`metadata\` KV with keys \`defaults.{org,project,telemetry,url}\`; getters/setters in \`src/lib/db/defaults.ts\`. Prefer dedicated SQLite tables + migrations over \`metadata\` KV for non-trivial caches. Hidden global \`--org\`/\`--project\` flags: \`mergeGlobalFlags()\` in command.ts injects hidden flag shapes, \`applyOrgProjectFlags()\` writes to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` before auth guard. No short aliases (\`-p\` conflicts). \`@sentry/api\` SDK: wrap types at \`src/lib/api/\*.ts\` with \`as unknown as SentryX\` casts; never leak to commands. \`unwrapResult\`/\`unwrapPaginatedResult\` must stay CLI-owned. \`apiRequestToRegion\` auto-sets JSON Content-Type; \`rawApiRequest\` preserves strings. @@ -56,9 +56,6 @@ ### Gotcha - -* **MastraClient has no dispose API — use AbortController for cleanup**: MastraClient has no \`close()\`/\`dispose()\` API — cleanup via \`ClientOptions.abortSignal\` (constructor) or per-prompt \`signal\`. Without explicit abort, fetch keep-alive sockets hold the event loop alive past natural exit. Pattern in \`src/lib/init/wizard-runner.ts\`: create \`AbortController\` per \`runWizard\`, pass \`abortSignal: controller.signal\` to \`new MastraClient(...)\`, abort via \`using \_ = { \[Symbol.dispose]: () => controller.abort() }\`. Custom \`fetch\` wrapper must preserve \`init.signal\` via spread. Tests capture \`ClientOptions\` via \`spyOn(MastraClient.prototype, 'getWorkflow').mockImplementation(function() { capturedOpts.push(this.options); ... })\`. - * **process.stdin.isTTY unreliable in Bun — use isatty(0) and backfill for clack**: \`process.stdin.isTTY\` unreliable — use \`isatty(0)\` from \`node:tty\`. Bun's single-file binary can leave \`process.stdin.isTTY === undefined\` on TTY fds. \`@clack/core\` gates \`setRawMode(true)\` on \`input.isTTY\`, silently disabling raw mode. Fix: backfill \`process.stdin.isTTY = true\` when \`isatty(0)\` confirms. Debugging: \`src/lib/init/tty-diagnostics.ts\` \`dumpTtyDiagnostics(label)\` — no-op unless \`SENTRY\_INIT\_DIAGNOSTICS=1\`. @@ -66,10 +63,10 @@ * **SQLite transaction() ROLLBACK can throw, discarding original error**: (gotcha) SQLite transaction ROLLBACK error-swallowing trap: In \`src/lib/db/sqlite.ts\`, \`transaction()\` catches errors and runs \`this.db.exec('ROLLBACK')\`. If ROLLBACK itself throws, the original error is lost. Fix: \`const origErr = e; try { this.db.exec('ROLLBACK'); } catch (rbErr) { log.debug(...); } throw origErr;\` -* **Vitest worker pool requires pool:forks + UV\_USE\_IO\_URING=0 on GitHub Actions**: (gotcha) Vitest/CI issues: (1) GitHub Actions io\_uring crashes Node.js workers (exit 134/SIGABRT) — fix: \`pool: 'forks'\` in \`vitest.config.ts\` AND \`UV\_USE\_IO\_URING=0\` in CI. (2) npm build smoke test: \`setup-node\` with \`node-version\` must not be deleted from npm build CI job. (3) Vitest 4: options must be second arg: \`test(name, { timeout }, fn)\`; bare numeric \`beforeAll(fn, 60\_000)\` remains valid. (4) \`http.createServer(async ...)\` — unhandled rejections crash test server; wrap body in try/catch. (5) \`dorny/paths-filter\` diffs against base — empty commits produce all-false outputs, silently skipping jobs. (6) \`node:sqlite\` requires \`--experimental-sqlite\` on Node 22 — top-level import crashes before any try/catch. (7) Lazy \`require()\` in test fixtures bypasses Vite's \`.js→.ts\` resolver — use top-level \`import\`. (8) \`spawn(process.execPath, \[workerScript.ts])\` fails under vitest/Node — use \`spawn('tsx', \[workerScript.ts])\` instead. +* **Vitest worker pool requires pool:forks + UV\_USE\_IO\_URING=0 on GitHub Actions**: Vitest/CI gotchas: (1) GitHub Actions io\_uring crashes Node.js workers (exit 134/SIGABRT) — fix: \`pool: 'forks'\` in \`vitest.config.ts\` AND \`UV\_USE\_IO\_URING=0\` in CI. (2) Vitest 4: options must be second arg: \`test(name, { timeout }, fn)\`. (3) \`http.createServer(async ...)\` — unhandled rejections crash test server; wrap body in try/catch. (4) \`dorny/paths-filter\` diffs against base — empty commits produce all-false outputs. (5) \`node:sqlite\` requires \`--experimental-sqlite\` on Node 22. (6) Lazy \`require()\` in test fixtures bypasses Vite's \`.js→.ts\` resolver — use top-level \`import\`. (7) \`spawn(process.execPath, \[workerScript.ts])\` fails under vitest/Node — use \`spawn('tsx', \[workerScript.ts])\`. Project uses vitest (migrated from bun:test); imports from 'vitest', mocks via \`vi.mock()\`. -* **Whole-buffer matchAll slower than split+test when aggregated over many files**: (gotcha) Grep/scan traps in \`src/lib/scan/\`: (1) Whole-buffer \`regex.exec\` 12× faster per-file but ~1.6× SLOWER over 10k files — early-exit at \`maxResults\` via \`mapFilesConcurrent.onResult\` wins. (2) Literal prefilter is FILE-LEVEL gate (\`indexOf\`→skip); per-line verify breaks cross-newline patterns and Unicode length-changing \`toLowerCase\`. (3) Extractor \`hasTopLevelAlternation\`+\`skipGroup\` must call \`skipCharacterClass\` (PCRE \`\[]abc]\` ≠ JS empty class). (4) Wake-latch race: use latched \`pendingWake\` flag, not \`let notify=null; await new Promise(r=>notify=r)\`. (5) \`mapFilesConcurrent\` filters \`null\` but NOT \`\[]\` — return \`null\` for no-op files. (6) \`collectGlob\`/\`collectGrep\` must NOT forward \`maxResults\` to iterator; drain uncapped, set \`truncated=true\`. +* **Whole-buffer matchAll slower than split+test when aggregated over many files**: Grep/scan traps in \`src/lib/scan/\`: (1) Whole-buffer \`regex.exec\` 12× faster per-file but ~1.6× SLOWER over 10k files — early-exit at \`maxResults\` via \`mapFilesConcurrent.onResult\` wins. (2) Literal prefilter is FILE-LEVEL gate (\`indexOf\`→skip); per-line verify breaks cross-newline patterns and Unicode length-changing \`toLowerCase\`. (3) Extractor \`hasTopLevelAlternation\`+\`skipGroup\` must call \`skipCharacterClass\`. (4) Wake-latch race: use latched \`pendingWake\` flag, not \`let notify=null; await new Promise(r=>notify=r)\`. (5) \`mapFilesConcurrent\` filters \`null\` but NOT \`\[]\` — return \`null\` for no-op files. (6) \`collectGlob\`/\`collectGrep\` must NOT forward \`maxResults\` to iterator; drain uncapped, set \`truncated=true\`. Worker pool: lazy singleton, size \`min(8, max(2, availableParallelism()))\`. Matches encoded as \`Uint32Array\` quads transferred via \`postMessage\` (~40% faster). \`new Worker(new URL(...))\` HANGS in SEA binaries — use Blob+URL.createObjectURL. FIFO \`pending\` queue per worker. \`ref()\`/\`unref()\` idempotent — only unref when \`inflight\` drops to 0. Disable via \`SENTRY\_SCAN\_DISABLE\_WORKERS=1\`. ### Pattern @@ -82,10 +79,7 @@ ### Preference -* **Always honor Retry-After header when present in LLM adapter**: (architecture) LLM adapter backoff in \`packages/gateway/src/llm-adapter.ts\`: Always honor Retry-After — \`backoffMs()\` returns \`Math.min(retryAfterMs, cap)\` where cap is \`RETRY\_AFTER\_CAP\_URGENT\_MS=8\_000\` or \`RETRY\_AFTER\_CAP\_BACKGROUND\_MS=120\_000\`. TRANSIENT\_CODES={429,500,502,503,529}; MAX\_RETRIES: rate-limit=3, server=3, urgent=2. Backoff (no Retry-After): 429 background=60s/120s/180s; urgent=min(1000×2^n,4000); 5xx background=min(1000×2^n,8000). Bearer tokens inject \`billingBlock\` as first system block; \`signBody()\` replaces \`cch=00000\` with xxHash64. System prompt caching uses \`cache\_control:{type:'ephemeral',ttl:'1h'}\`. \`opts.thinking\` NOT forwarded to bare API calls. Circuit breaker tripped on non-urgent 429s via \`tripCircuitBreaker()\`. - - -* **Always migrate Bun-specific APIs to Node.js equivalents in src/ directory**: (preference) Migrate all Bun-specific APIs in \`src/\` to Node.js equivalents: \`Bun.file()\`/\`Bun.write()\` → \`node:fs/promises\`, \`Bun.spawn()\`/\`Bun.spawnSync()\` → \`node:child\_process\`, \`Bun.sleep()\` → \`node:timers/promises\`, \`Bun.Glob\` → \`picomatch\`, \`Bun.which()\` → custom helper, \`bun:sqlite\` → \`node:sqlite\`, \`Bun.randomUUIDv7()\` → \`uuidv7()\`, \`Bun.semver\` → \`semver\`. Exception: \`Bun.build()\` in \`script/build.ts\`. When replacing: attach \`'error'\` listeners on spawned processes, use error-first callbacks for stream \`.end()\`, remove \`@types/bun\` and lock files (\`bun.lock\`, \`bunfig.toml\`). User directives override cursor rules unconditionally — delete \`.cursor/rules/bun-cli.mdc\`. After migration, remove all \`isBun\` skip guards from affected test files (they become dead exclusions under vitest/Node). \`script/node-polyfills.ts\` provides \`globalThis.Bun\` shim for Node runtime; remove polyfill entries as \`src/\` migrates. Migration PRs warrant extra scrutiny for subtle regressions. +* **Always honor Retry-After header when present in LLM adapter**: (architecture) LLM adapter backoff in \`packages/gateway/src/llm-adapter.ts\`: Always honor Retry-After — \`backoffMs()\` returns \`Math.min(retryAfterMs, cap)\` where cap is \`RETRY\_AFTER\_CAP\_URGENT\_MS=8\_000\` or \`RETRY\_AFTER\_CAP\_BACKGROUND\_MS=120\_000\`. TRANSIENT\_CODES={429,500,502,503,529}; MAX\_RETRIES: rate-limit=3, server=3, urgent=2. Backoff (no Retry-After): 429 background=60s/120s/180s; urgent=min(1000×2^n,4000); 5xx background=min(1000×2^n,8000). Bearer tokens inject \`billingBlock\` as first system block; \`signBody()\` replaces \`cch=00000\` with xxHash64. System prompt caching uses \`cache\_control:{type:'ephemeral',ttl:'1h'}\`. \`opts.thinking\` NOT forwarded to bare API calls. Circuit breaker tripped on non-urgent 429s via \`tripCircuitBreaker()\`. Gateway auth (\`packages/gateway/src/auth.ts\`): \`AuthCredential\` (api-key|bearer). Two-level lookup: \`sessionAuth\` Map → \`lastSeenAuth\` global fallback via \`resolveAuth(sessionID?)\`. \`authFingerprint()\` = SHA-256 truncated to 16 hex chars. - -* **Always perform critical code review before merging PRs, identifying bugs by severity tier**: (preference) PR reviews must use severity tiers: Critical (incorrect behavior, security vulnerabilities, silent error swallowing), Medium (stale comments, misleading naming, latent bugs), Low (style). Each finding needs file path, line number, and concrete fix. Verdict: APPROVE or REQUEST\_CHANGES. Migration PRs (e.g., Bun→Node) warrant extra scrutiny for subtle regressions and indirect breakage from removals. + +* **Prefers Bun-native APIs; use buildCommand from lib/command.js (never @stricli/core directly); use buildRouteMap from lib/route-map.js; silent catch blocks prohibited; every new src/lib/\*\*/\*.ts must start with module-level JSDoc; test isolation via useTestConfigDir(); prefer property-based and model-based tests over unit tests; DEFAULT\_NUM\_RUNS = 50; architecture tree documented; error exit code ranges: 1x=auth**: Project conventions (AGENTS.md): use \`pnpm run\`/\`pnpm install\`/\`pnpm add -D\` (NOT bun); use buildCommand from lib/command.js (never @stricli/core directly); use buildRouteMap from lib/route-map.js; silent catch blocks prohibited; every new src/lib/\*\*/\*.ts must start with module-level JSDoc; test isolation via useTestConfigDir(); prefer property-based and model-based tests over unit tests; DEFAULT\_NUM\_RUNS=50; error exit code ranges: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, 5x=operations, 6x=command-specific. Testing: vitest + fast-check (migrated from bun:test). All packages in devDependencies (CI enforces via \`pnpm run check:deps\`). Always check package.json for latest scripts. diff --git a/script/build.ts b/script/build.ts index 8ff06eaa8..9db8e522c 100644 --- a/script/build.ts +++ b/script/build.ts @@ -470,7 +470,7 @@ async function build(): Promise { await uploadSourcemapToSentry(); // Clean up intermediate build directory (only the binaries are artifacts). - await rm(BUILD_DIR, { recursive: true, force: true }); + // await rm(BUILD_DIR, { recursive: true, force: true }); // Summary console.log(`\n${"=".repeat(40)}`); diff --git a/script/require-shim.mjs b/script/require-shim.mjs index d299d1eb5..54ccb33ff 100644 --- a/script/require-shim.mjs +++ b/script/require-shim.mjs @@ -1,5 +1,6 @@ /** - * ESM preload shim that provides `require` in ESM modules. + * ESM preload shim that provides `require` in ESM modules and handles + * `with { type: "file" }` import attributes in tsx dev mode. * * The source code uses bare `require()` for lazy loading (circular dependency * breaking, optional features). This works natively in Bun and in the CJS @@ -8,19 +9,43 @@ * * The `require` function is anchored at the project root (package.json) so * that `node:*` builtins and npm package requires resolve correctly. Note - * that relative `require("./foo.js")` calls will resolve from the project - * root, not from the calling file — this is acceptable because all relative - * `require()` calls in `src/` are behind runtime-only code paths (DB init, - * telemetry) that don't execute during tsx script runs. + * that relative `require("./foo.js")` calls resolve from the project root, + * not from the calling file. Files in `src/` that use lazy relative requires + * must use a file-local `createRequire(import.meta.url)` instead of relying + * on this global shim. + * + * `with { type: "file" }` import attributes are used to embed sidecar files + * (e.g. the Ink UI app). Bun supports this natively; esbuild's + * text-import-plugin handles it at build time. In tsx dev mode neither + * applies, so we register a loader hook that returns the file path as a + * string — matching Bun's native behaviour. * * Usage: NODE_OPTIONS="--import ./script/require-shim.mjs" tsx script/... * Or in package.json scripts via the `pnpm tsx` alias. */ -import { createRequire } from "node:module"; +import { createRequire, registerHooks } from "node:module"; if (typeof globalThis.require === "undefined") { globalThis.require = createRequire( new URL("../package.json", import.meta.url) ); } + +// Handle `with { type: "file" }` import attributes in Node.js dev mode. +// Bun supports this natively; esbuild's text-import-plugin handles it at +// build time. In tsx dev mode neither applies, so we register a synchronous +// hook that returns the file path as a string — matching Bun's behaviour. +// registerHooks() is available from Node 22.15+ (our minimum). +registerHooks({ + load(url, context, nextLoad) { + if (context.importAttributes?.type === "file") { + return { + format: "module", + shortCircuit: true, + source: `export default ${JSON.stringify(new URL(url).pathname)};`, + }; + } + return nextLoad(url, context); + }, +}); diff --git a/src/lib/custom-ca.ts b/src/lib/custom-ca.ts index 9c16a6344..7e87e0ba1 100644 --- a/src/lib/custom-ca.ts +++ b/src/lib/custom-ca.ts @@ -17,7 +17,10 @@ */ import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { rootCertificates } from "node:tls"; + +const _require = createRequire(import.meta.url); import { getDefaultCaCert } from "./db/defaults.js"; import { getEnv } from "./env.js"; import { logger } from "./logger.js"; @@ -30,7 +33,7 @@ import { isSentrySaasUrl } from "./sentry-urls.js"; * option instead. */ const setDefaultCACertificates: ((certs: string[]) => void) | undefined = ( - require("node:tls") as { + _require("node:tls") as { setDefaultCACertificates?: (certs: string[]) => void; } ).setDefaultCACertificates; diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 64a7dd237..ba1f9f69a 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -5,8 +5,12 @@ */ import { chmodSync, mkdirSync } from "node:fs"; +import { createRequire } from "node:module"; import { join } from "node:path"; import { getEnv } from "../env.js"; + +const _require = createRequire(import.meta.url); + import { migrateFromJson } from "./migration.js"; import { initSchema, runMigrations } from "./schema.js"; import { Database } from "./sqlite.js"; @@ -30,7 +34,7 @@ let rawDb: Database | null = null; let dbOpenedPath: string | null = null; export function getConfigDir(): string { - const { homedir } = require("node:os"); + const { homedir } = _require("node:os"); return ( getEnv()[CONFIG_DIR_ENV_VAR] || join(homedir(), DEFAULT_CONFIG_DIR_NAME) ); @@ -107,6 +111,7 @@ export function getDatabase(): Database { if (getEnv().SENTRY_CLI_NO_TELEMETRY === "1") { db = rawDb; } else { + // bare require so esbuild resolves this at bundle time (breaks circular dep) const { createTracedDatabase } = require("../telemetry.js") as { createTracedDatabase: (d: Database) => Database; }; diff --git a/src/lib/db/migration.ts b/src/lib/db/migration.ts index a185be3dd..16ce9c593 100644 --- a/src/lib/db/migration.ts +++ b/src/lib/db/migration.ts @@ -3,7 +3,11 @@ */ import { rmSync } from "node:fs"; +import { createRequire } from "node:module"; import { join } from "node:path"; + +const _require = createRequire(import.meta.url); + import { logger } from "../logger.js"; import { getConfigDir } from "./index.js"; import type { Database } from "./sqlite.js"; @@ -34,14 +38,14 @@ function markMigrationCompleted(db: Database): void { function oldConfigExists(): boolean { const configPath = join(getConfigDir(), OLD_CONFIG_FILENAME); - const { existsSync } = require("node:fs"); + const { existsSync } = _require("node:fs"); return existsSync(configPath); } function readOldConfig(): OldConfig | null { const configPath = join(getConfigDir(), OLD_CONFIG_FILENAME); try { - const { readFileSync } = require("node:fs"); + const { readFileSync } = _require("node:fs"); const content = readFileSync(configPath, "utf-8"); return JSON.parse(content); } catch { diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index cacb8dad8..b5ea7a268 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -11,11 +11,14 @@ * - Migration checks */ +import { createRequire } from "node:module"; import { getEnv } from "../env.js"; import { stringifyUnknown } from "../errors.js"; import { logger } from "../logger.js"; import type { Database } from "./sqlite.js"; +const _require = createRequire(import.meta.url); + export const CURRENT_SCHEMA_VERSION = 16; /** Environment variable to disable auto-repair */ @@ -671,6 +674,7 @@ export function tryRepairAndRetry( let repairSucceeded = false; try { // Dynamic imports to avoid circular dependencies with db/index.js + // bare require so esbuild resolves this at bundle time (breaks circular dep) const { getRawDatabase } = require("./index.js") as { getRawDatabase: () => Database; }; diff --git a/src/lib/db/sqlite.ts b/src/lib/db/sqlite.ts index 1ee3aa6f0..43dea2513 100644 --- a/src/lib/db/sqlite.ts +++ b/src/lib/db/sqlite.ts @@ -8,8 +8,11 @@ * Uses `node:sqlite` (Node 22.15+ with `--experimental-sqlite` flag). */ +import { createRequire } from "node:module"; import { logger } from "../logger.js"; +const _require = createRequire(import.meta.url); + const log = logger.withTag("sqlite"); /** Valid SQLite binding value. */ @@ -58,7 +61,7 @@ function wrapStatement(stmt: any): StatementWrapper { * Uses `node:sqlite` (Node 22.15+ with `--experimental-sqlite`). */ // biome-ignore lint/suspicious/noExplicitAny: driver types loaded lazily -const SqliteImpl: any = require("node:sqlite").DatabaseSync; +const SqliteImpl: any = _require("node:sqlite").DatabaseSync; /** * SQLite database wrapper. diff --git a/src/lib/init/ui/ink-ui.ts b/src/lib/init/ui/ink-ui.ts index c9f032b23..cfc52181a 100644 --- a/src/lib/init/ui/ink-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -47,7 +47,11 @@ */ import { openSync } from "node:fs"; +import { createRequire } from "node:module"; import { ReadStream } from "node:tty"; + +const _require = createRequire(import.meta.url); + import { setTag } from "@sentry/node-core/light"; import { CLI_VERSION } from "../../constants.js"; import { stripAnsi } from "../../formatters/plain-detect.js"; @@ -235,7 +239,7 @@ export async function createInkUI( let isSea = false; try { // biome-ignore lint/suspicious/noExplicitAny: node:sea types not yet in @types/node - const sea = require("node:sea") as any; + const sea = _require("node:sea") as any; isSea = sea.isSea?.() === true; } catch { // node:sea not available (older Node or non-SEA context) @@ -245,7 +249,7 @@ export async function createInkUI( // Extract the embedded sidecar to a temp file and import it. // The asset key matches what fossilize registered via --assets. // biome-ignore lint/suspicious/noExplicitAny: node:sea types not yet in @types/node - const sea = require("node:sea") as any; + const sea = _require("node:sea") as any; const { writeFileSync, mkdtempSync } = await import("node:fs"); const { join } = await import("node:path"); const { tmpdir } = await import("node:os"); diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index a9d5fc37e..257427696 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -13,8 +13,12 @@ * buildOrgListCommand */ +import { createRequire } from "node:module"; import type { Aliases, Command, CommandContext } from "@stricli/core"; import type { SentryContext } from "../context.js"; + +const _require = createRequire(import.meta.url); + import { parseOrgProjectArg } from "./arg-parsing.js"; import { buildCommand, numberParser } from "./command.js"; import { disableOrgCache } from "./db/regions.js"; @@ -414,6 +418,7 @@ function getSubcommandsForRoute(routeName: string): Set { _subcommandsByRoute = new Map(); try { + // bare require so esbuild resolves this at bundle time (breaks circular dep) const { routes } = require("../app.js") as { routes: { getAllEntries: () => readonly RouteEntry[] }; }; diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 272b3e959..fe28ba47b 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -56,8 +56,12 @@ * @module */ +import { createRequire } from "node:module"; import type { ConsolaInstance } from "consola"; import { createConsola } from "consola"; + +const _require = createRequire(import.meta.url); + import { getEnv } from "./env.js"; /** @@ -209,7 +213,7 @@ export function attachSentryReporter(): void { // Dynamic import to avoid pulling in Sentry at module load time. // The reporter is exported from @sentry/node-core/light (via @sentry/node → @sentry/core). // eslint-disable-next-line @typescript-eslint/no-require-imports - const Sentry = require("@sentry/node-core/light") as { + const Sentry = _require("@sentry/node-core/light") as { createConsolaReporter: (options?: Record) => { log: (logObj: unknown) => void; }; diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index cb1e6bb9c..eab827cac 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -10,8 +10,12 @@ */ import { chmodSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; + +const _require = createRequire(import.meta.url); + import { isMusl } from "./binary.js"; import { CLI_VERSION, @@ -383,7 +387,7 @@ const LIBRARY_EXCLUDED_INTEGRATIONS = new Set([ const hasGetSystemErrorMap = (() => { try { // Dynamic require to avoid bundler issues — the check only matters at runtime - const util = require("node:util") as Record; + const util = _require("node:util") as Record; return typeof util.getSystemErrorMap === "function"; } catch { return false; @@ -1017,6 +1021,7 @@ const noop = (): void => {}; /** Resolves the database path, falling back to a default if the import fails. */ function resolveDbPath(): string { try { + // bare require so esbuild resolves this at bundle time (breaks circular dep) const { getDbPath } = require("./db/index.js") as { getDbPath: () => string; }; @@ -1102,7 +1107,7 @@ function tryRepairReadonly(): boolean { repairAttempted = true; const dbPath = resolveDbPath(); - const { dirname } = require("node:path") as { + const { dirname } = _require("node:path") as { dirname: (p: string) => string; }; const configDir = dirname(dbPath); diff --git a/test/commands/cli/setup.test.ts b/test/commands/cli/setup.test.ts index 5ad2f0baf..2127f3c40 100644 --- a/test/commands/cli/setup.test.ts +++ b/test/commands/cli/setup.test.ts @@ -864,7 +864,7 @@ describe("sentry cli setup", () => { }); test("setup completes gracefully when completion directory is not writable", async () => { - // Make the completions dir unwritable so Bun.write() can't write the + // Make the completions dir unwritable so write() can't write the // completion script. installCompletions() catches the permission error // and returns null — setup completes without error or warning. const { chmodSync: chmod } = await import("node:fs"); diff --git a/test/e2e/library.test.ts b/test/e2e/library.test.ts index ec287e429..b5d54d28c 100644 --- a/test/e2e/library.test.ts +++ b/test/e2e/library.test.ts @@ -35,7 +35,7 @@ async function runNodeScript( SENTRY_CLI_NO_TELEMETRY: "1", }; // Ensure no auth leaks into tests — delete rather than set to undefined - // because Bun.spawn may pass "undefined" as a literal string + // because spawn may pass "undefined" as a literal string delete env.SENTRY_AUTH_TOKEN; delete env.SENTRY_TOKEN; diff --git a/test/lib/telemetry.test.ts b/test/lib/telemetry.test.ts index 18b0cadf8..b4026fe49 100644 --- a/test/lib/telemetry.test.ts +++ b/test/lib/telemetry.test.ts @@ -943,97 +943,6 @@ describe("createTracedDatabase", () => { db.close(); }); - // Note: The auto-repair tests depend on tryRepairReadonly() resolving - // the correct DB path via resolveDbPath(). When the test opens a custom - // DB file directly (not via getDatabase()), the repair targets the global - // config DB instead. Under bun:sqlite the repaired connection resumes - // writes; under Node.js sqlite the connection remains readonly. - // These tests are skipped on Node.js where the behavior differs. - const isBunSqlite = typeof globalThis.Bun !== "undefined"; - - test.skipIf(!isBunSqlite)( - "auto-repairs permissions on first readonly write", - () => { - const db = new Database(dbPath); - const tracedDb = createTracedDatabase(db); - - const stderrSpy = vi.spyOn(process.stderr, "write"); - - tracedDb - .query("INSERT INTO test (id, name) VALUES (?, ?)") - .run(2, "Bob"); - - const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); - expect(output).toContain("auto-repaired"); - expect(output).toContain("next command"); - - stderrSpy.mockRestore(); - db.close(); - } - ); - - test.skipIf(!isBunSqlite)( - "shows only one message across multiple writes", - () => { - const db = new Database(dbPath); - const tracedDb = createTracedDatabase(db); - - const stderrSpy = vi.spyOn(process.stderr, "write"); - - tracedDb - .query("INSERT INTO test (id, name) VALUES (?, ?)") - .run(2, "Bob"); - tracedDb - .query("INSERT INTO test (id, name) VALUES (?, ?)") - .run(3, "Charlie"); - tracedDb - .query("INSERT INTO test (id, name) VALUES (?, ?)") - .run(4, "Dave"); - - // Only one message total (the auto-repair note) - expect(stderrSpy.mock.calls.length).toBe(1); - - stderrSpy.mockRestore(); - db.close(); - } - ); - - test.skipIf(!isBunSqlite)( - "resetReadonlyWarning allows auto-repair to trigger again", - () => { - const db = new Database(dbPath); - const tracedDb = createTracedDatabase(db); - - const stderrSpy = vi.spyOn(process.stderr, "write"); - - // First write triggers auto-repair - tracedDb - .query("INSERT INTO test (id, name) VALUES (?, ?)") - .run(2, "Bob"); - expect(stderrSpy.mock.calls.length).toBe(1); - expect(String(stderrSpy.mock.calls[0]?.[0])).toContain("auto-repaired"); - - // Second write is silent (one-shot guard) - tracedDb.query("INSERT INTO test (id, name) VALUES (?, ?)").run(3, "X"); - expect(stderrSpy.mock.calls.length).toBe(1); - - // Reset all state - resetReadonlyWarning(); - stderrSpy.mockClear(); - - // Re-break permissions so SQLite errors again - chmodSync(dbPath, 0o444); - - // Next write triggers auto-repair again after reset - tracedDb.query("INSERT INTO test (id, name) VALUES (?, ?)").run(4, "Y"); - expect(stderrSpy.mock.calls.length).toBe(1); - expect(String(stderrSpy.mock.calls[0]?.[0])).toContain("auto-repaired"); - - stderrSpy.mockRestore(); - db.close(); - } - ); - test("all() and values() return empty arrays on readonly write", () => { const db = new Database(dbPath); const tracedDb = createTracedDatabase(db);