From 08b129a7fdf9d2707fac3394c57d6aa200542739 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 23 Jun 2026 13:18:09 +0000 Subject: [PATCH 1/2] feat(debug-files): add bundle-sources command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `sentry debug-files bundle-sources `: builds a source bundle (a ZIP stamped with the object's debug id) from the source files referenced by a debug information file, for upload to Sentry for source context. - src/lib/dif/index.ts: add `createSourceBundle()` over 13.4.0's `SourceBundleWriter` — walks the object's referenced source paths and invokes a caller-supplied provider for each (returning null skips files not available locally); returns the bundle bytes, debug id, and file count. - src/commands/debug-files/bundle-sources.ts: the command. Reads referenced sources from disk synchronously; defaults output to `.src.zip` (`--output/-o` to override); exits non-zero (writing nothing) when no referenced sources are found on disk. Local-only, no API calls. - Extract the shared `readDebugFile` helper (read-file.ts) used by check + this. - Tests: createSourceBundle unit tests (Breakpad FILE-record fixture) + a bundle-sources command test. Docs fragment + generated skill/docs updated. Source files are read from the paths recorded in the debug info, so this is normally run on the build machine right after compiling. --- .lore.md | 81 ++++++++- docs/src/content/docs/contributing.md | 2 +- docs/src/fragments/commands/debug-files.md | 16 +- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../sentry-cli/references/debug-files.md | 11 ++ src/commands/debug-files/bundle-sources.ts | 171 ++++++++++++++++++ src/commands/debug-files/check.ts | 26 +-- src/commands/debug-files/index.ts | 2 + src/commands/debug-files/read-file.ts | 32 ++++ src/lib/dif/index.ts | 64 ++++++- .../debug-files/bundle-sources.test.ts | 139 ++++++++++++++ test/lib/dif/index.test.ts | 71 +++++++- 12 files changed, 576 insertions(+), 40 deletions(-) create mode 100644 src/commands/debug-files/bundle-sources.ts create mode 100644 src/commands/debug-files/read-file.ts create mode 100644 test/commands/debug-files/bundle-sources.test.ts diff --git a/.lore.md b/.lore.md index f88849f31..8b3eddac7 100644 --- a/.lore.md +++ b/.lore.md @@ -4,6 +4,9 @@ ### Architecture + +* **@sentry/symbolic 13.4.0 API surface: SourceBundleWriter for bundle-sources command**: \`@sentry/symbolic@13.4.0\` exports 4 classes: \`Archive\`, \`FileEntry\`, \`ObjectFile\`, \`SourceBundleWriter\`, plus \`SourceFileDescriptor\`. Key for CLI source-tier commands: \`SourceBundleWriter.writeObject(object: ObjectFile, object\_name: string, filter: Function, provider: Function): Uint8Array | undefined\` — callback-based; provider reads source content by path, filter selects files. \`bundle-sources\` is directly implementable (provider reads from disk). \`print-sources\` is BLOCKED — \`ObjectFile\` has no \`sourceFiles()\` enumeration method in 13.4.0 (only props: arch, codeId, debugId, fileFormat, hasDebugInfo, hasSources, hasSymbols, hasUnwindInfo, kind). \`SourceFileDescriptor\` has get/set props: contents, debugId, path, sourceMappingUrl, url, type. Confirmed by Dav1dde (Sebastian Zivota's colleague) on Jun 23 2026. + * **403/401 enrichment pipeline in infrastructure.ts — centralized, no interactive auto-fix**: \`src/lib/api/infrastructure.ts\` centralizes HTTP error enrichment. \`enrichDetail()\` dispatches: 403→\`enrich403Detail\`, 401→\`enrich401Detail\`, others pass raw. \`enrich403Detail()\` three branches: (1) \`rawDetail.includes('disabled this feature')\` → org-policy message; (2) \`isEnvTokenActive()\` → \`extractRequiredScopes(rawDetail)\`; scopes found → definite missing-scope message; no scopes → hedged message; (3) OAuth → re-auth suggestion. \`throwApiError()\` and \`throwRawApiError()\` both set \`ApiError.enriched403=true\`. No interactive auto-fix. \`buildPermissionError()\` in \`project/delete.ts\` NEVER suggests \`sentry auth login\` — re-auth via OAuth won't change permissions. OAuth \`auth login\` always grants required scopes, so scope hints only apply to env-var tokens. 401 errors: fix is always re-authenticate — scope hints do NOT apply. @@ -11,11 +14,14 @@ * **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)**: Binary build pipeline: \`src/bin.ts → \[esbuild CJS, node24 target] → dist-build/bin.js → \[fossilize --no-bundle] → Node SEA binary → \[binpunch ICU hole-punch] → gzip\`. Strip debug symbols handled INSIDE fossilize (as of fossilize 0.7.0) — fossilize strips the copied binary BEFORE postject injection. Strip MUST happen before injection — after SEA injection, \`strip\` fails ('section .text can't be allocated in segment 2'). macOS: \`strip -x\` on unsigned copy; cross-strip from Linux silently fails (caught). Windows: skipped (no debug symbols). NODE\_VERSION='lts'. ALL\_TARGETS: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64 + musl variants. Post-process: rename \`sentry-win-x64.exe\`→\`sentry-windows-x64.exe\`. UPX RULED OUT — destroys ELF notes. \`FOSSILIZE\_SIGN=y\` on push to main/release. Gzip only when \`RELEASE\_BUILD=1\`. \`stripCachedNodeBinaries()\` removed from \`script/build.ts\` — superseded by fossilize 0.7.0. +* **Binary build pipeline: esbuild → fossilize → Node SEA (replacing Bun.build compile)**: Binary build pipeline: \`src/bin.ts → \[esbuild CJS, node24] → dist-build/bin.js → \[fossilize --no-bundle --hole-punch] → Node SEA binary → gzip\`. CRITICAL ORDER: hole-punch BEFORE signing — hole-punching after signing invalidates macOS code signature (AMFI SIGKILL). fossilize 0.8.0+ runs hole-punch via \`--hole-punch\` between chmod and sign+notarize. Strip debug symbols handled inside fossilize (v0.7.0+). macOS: \`strip -x\` on unsigned copy; cross-strip from Linux silently fails. UPX RULED OUT — destroys ELF notes. ALL\_TARGETS: darwin-arm64/x64, linux-arm64/x64, win32-x64 + musl variants. Post-process: rename \`sentry-win-x64.exe\`→\`sentry-windows-x64.exe\`. \`FOSSILIZE\_SIGN=y\` on push to main/release. \`useSnapshot:true\` BROKEN; \`useCodeCache:true\` ~15% startup improvement. fossilize 0.8.1 fixes cross-compile strip crash. Runtime modes: (1) SEA binary — \`process.execPath\` IS the sentry binary; (2) npm package — \`process.execPath\` is Node.js, \`process.argv\[1]\` is script in node\_modules, bin field: \`'sentry': './dist/bin.cjs'\`. * **Binary size breakdown: 94.5% is Node.js runtime — bundled code is ~6.3 MiB**: Binary composition (linux-x64, Node 24 LTS): Node.js runtime=121 MiB (ships with debug symbols). \`strip --strip-unneeded\` → 99 MiB (-17 MiB raw, -4 MiB compressed). Strip built into fossilize 0.7.0 — happens on the copied binary BEFORE postject injection. After strip+SEA+binpunch: ~108 MiB raw, ~30 MiB gzip (vs 125 MiB / 34 MiB unstripped). .rodata=52.5 MB: V8 snapshot ~12 MB, ICU full-icu data ~28 MB. UPX compresses to 25 MiB but DESTROYS ELF notes — ruled out. \`--with-intl=small-icu\` saves ~26-28 MiB (biggest win from custom build); \`--without-lief\` BREAKS SEA; \`--without-sqlite\` BREAKS CLI; \`--disable-single-executable-application\` BREAKS EVERYTHING. Custom build deferred — poor cost/benefit (~3.5h build vs 5min fossilize). Final vs Bun: download 30 MiB (Bun: 32 MiB), \`--version\` ~1.0s (Bun: ~1.9s), completions ~150ms (Bun: ~180ms). + +* **bspatch.ts in-memory chain refactor: transformPatch callback + three public APIs**: Core patching logic extracted into \`transformPatch(oldFile, patchData, onChunk)\` callback-based function. Three public APIs: \`applyPatchToFile(oldPath, patchData, destPath)→SHA-256\` (disk sink, final hop); \`applyPatchToMemory(oldFile, patchData)→Uint8Array\` (in-memory, intermediate hops); \`applyPatchChainInMemory(oldPath, patches\[], destPath)→SHA-256\` (full chain orchestrator). \`applyPatch()\` kept as thin wrapper for backward compat. Orchestration lives in bspatch.ts (not delta-upgrade.ts) to keep buffer handling encapsulated. \`onChunk\` callback checks \`writeError\` flag set by writer 'error' event — throws immediately rather than waiting for top-of-loop check. \`applyPatchToMemory\` preallocates Uint8Array of \`newSize\`; corrupt patch claiming huge size throws RangeError → triggers full download fallback. + * **bspatch.ts: TRDIFF10 patch application — inline SHA-256, streaming zstd, CoW old-file copy**: TRDIFF10 header: 32 bytes (magic + controlLen + diffLen + newSize, all i64 LE). Control block decompressed fully via \`zstdDecompressSync\` (needs random access). Diff + extra blocks streamed via \`createZstdStreamReader\` (Node Transform → Web ReadableStream → \`BufferedStreamReader\`). \`applyPatch()\` ALWAYS computes SHA-256 inline and returns it — no separate verification step. \`loadOldBinary()\` copies to temp via \`COPYFILE\_FICLONE\` (CoW reflink, falls back to regular copy) then reads into memory. \`cleanupPatchResources()\` runs all cleanup steps regardless of prior failures. Write errors captured early via \`writer.on('error')\` to avoid ERR\_UNHANDLED\_ERROR on ENOSPC/EIO. @@ -29,7 +35,7 @@ * **collapse=lifetime in issue list: LIFETIME\_FIELDS, buildListApiOptions, and API gotcha**: \`src/commands/issue/list.ts\` \`LIFETIME\_FIELDS = new Set(\['count','userCount','firstSeen','lastSeen'])\` — fields stripped by \`collapse=lifetime\` on the list endpoint. \`buildListApiOptions(json, fields)\`: \`collapseLifetime\` only true when \`json && fields !== undefined && fields.length > 0 && !fields.some(f => LIFETIME\_FIELDS.has(f))\`. Human output NEVER collapses lifetime. \`buildIssueListCollapse()\` always starts with \`\['filtered','unhandled']\`, conditionally adds \`'lifetime'\` then \`'stats'\`. \`ISSUE\_DETAIL\_COLLAPSE\` safely includes \`'lifetime'\` — detail endpoint preserves top-level fields regardless. \`IssueViewOutputSchema\` in \`src/types/sentry.ts\` extends \`SentryIssueSchema\` with enrichment fields (\`event\`, \`org\`, \`replayIds\`, \`trace\`) added by \`jsonTransformIssueView\`. Wired via \`schema: IssueViewOutputSchema\` on output config in \`view.ts\`. NOTE: \`count\`/\`userCount\`/\`firstSeen\`/\`lastSeen\` always present on \`issue view\` (detail endpoint) — only potentially absent on \`issue list\` when collapse=lifetime is active. -* **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\`. +* **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 invariants (\`src/lib/telemetry.ts\`): (1) \`initSentry()\` ALWAYS removes \`currentBeforeExitHandler\` before registering new one. (2) \`isOwnedByRoot()\` returns \`false\` immediately on Windows. (3) NEVER block CLI — all drains are best-effort, wrapped in try/catch. (4) \`SENSITIVE\_ARGV\_FLAGS\` (\`token\`, \`auth-token\`) NEVER sent — \`redactArgv()\` handles both \`--flag=value\` and \`--flag \\` forms. \`runCompletion()\` sets \`SENTRY\_CLI\_NO\_TELEMETRY=1\`. Opt-out priority: (1) \`SENTRY\_CLI\_NO\_TELEMETRY=1\`, (2) \`DO\_NOT\_TRACK=1\`, (3) \`metadata.defaults.telemetry\`, (4) default on. \`ENV\_VAR\_REGISTRY\` in \`src/lib/env-registry.ts\` is single source for all honored env vars; \`topLevel: true\` + \`briefDescription\` surfaces in \`--help\`. * **Custom CA loading: priority, caching, TLS error detection, and SaaS warning**: Custom CA in \`src/lib/custom-ca.ts\`: Priority: (1) \`sentry cli defaults ca-cert\` (SQLite), (2) \`NODE\_EXTRA\_CA\_CERTS\`. Cached per-process via module-level vars (\`hasResolved\` flag). \`resolve()\` concatenates custom PEM with \`rootCertificates\` (additive — Bun replaces Mozilla bundle otherwise). \`tryReadPem()\` NEVER throws — missing CA file logs warn and returns \`undefined\`. \`injectIntoNodeTls()\` uses \`tls.setDefaultCACertificates()\` (Node 24+ only; no-op on Node 22). \`TLS\_ERROR\_PATTERNS\`: 5 patterns (local issuer, verify first cert, UNABLE\_TO\_VERIFY\_LEAF\_SIGNATURE, DEPTH\_ZERO\_SELF\_SIGNED\_CERT, SELF\_SIGNED\_CERT\_IN\_CHAIN) — explicitly excludes \`CERT\_HAS\_EXPIRED\` and \`ERR\_TLS\_CERT\_ALTNAME\_INVALID\`. \`getTlsCertErrorMessage()\` walks \`error.cause\` chain with cycle detection. SaaS target + env-sourced CA → one-time warning; stored default silences it. \`\_\_resetForTests()\` resets all cached state. @@ -49,6 +55,9 @@ * **generate:docs pipeline: 4-script sequence, prerequisites, and output ownership**: Master orchestrator: \`generate:docs\` runs 4 scripts in sequence: (1) \`generate:parser\` → \`script/generate-parser.ts\`, (2) \`generate:command-docs\` → \`script/generate-command-docs.ts\`, (3) \`generate:skill\` → \`script/generate-skill.ts\`, (4) \`generate:docs-sections\` → \`script/generate-docs-sections.ts\`. Prerequisite for: \`dev\`, \`build\`, \`build:all\`, \`bundle\`, \`typecheck\`, \`test:unit\`, \`test:changed\`, \`test:e2e\`. Output ownership: \`docs/src/content/docs/commands/\` and \`docs/src/content/docs/configuration.md\` are gitignored (fully generated). \`docs/src/fragments/\` files are committed source of truth (hand-written custom content). \`DEVELOPMENT.md\`, \`README.md\`, \`contributing.md\`, \`self-hosted.md\`, \`getting-started.mdx\` are committed but have in-place injected sections between named markers. + +* **getsentry/symbolic WASM architecture: zstd via C zstd-sys wasm-shim, self\_cell ownership**: WASM build uses C zstd via \`zstd-sys\` for ALL targets including \`wasm32-unknown-unknown\`. \`zstd-sys\` ships \`wasm-shim/\` with C headers; \`build.rs\` auto-enables shim for wasm32. CI \`wasm-build\` job installs \`clang lld llvm\`. ruzstd dropped (significantly slower per crate author + Sebastian Zivota). Ownership model: \`self\_cell\`-based (\`SelfCell\, di::Archive<'static>>\`). \`ObjectFile\` rename fix: \`#\[wasm\_bindgen(js\_name = "ObjectFile")]\`. Canonical field names use \`.name()\` method (lowercase: \`elf\`, \`x86\_64\`) not \`{:?}\` Debug formatting. Smoke tests in \`symbolic-wasm/npm/\` so \`npm test\` works from npm dir. PR sequencing: symbolic PRs (#988, #992) must merge + republish before CLI \`bundle-sources\` PR. + * **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\`. @@ -87,6 +96,12 @@ * **Adopted workspace deps, cfg-zstd, required wasm-opt conventions from #989; kept #988 additions (serde\_bytes**: PR #988 (\`feat/source-bundle-provider\`, MERGED): \`write\_object\_with\_source\_provider\` + \`write\_object\_with\_filter\` delegation. PR #989 (\`fix/symbolic-followups\`, MERGED, @sentry/symbolic@13.3.1): workspace deps, cfg-zstd, required wasm-opt conventions. PR #990 (C zstd on wasm, drop ruzstd, MERGED). PR #991 (\`feat/wasm-api-classes\`): CLOSED — superseded by Dav1dde's PR #992. PR #992 (\`feat(wasm): Expose lower level API for debuginfo, add tests\`, OPEN): Dav1dde's \`self\_cell\`-based foundational API; +392/-101 across 11 files. Branch \`prototype/wasm-artifact-smoke\` (off #992 head \`fd94b6fe\`): adds artifact smoke test + \`ObjectFile\` rename fix; 5 files, +122/-6. SourceBundleWriter not yet in #992 — planned EOD Jun 23 2026. BYK will migrate \`debug-files check\` onto new API when republished. + +* **bspatch.ts: no mmap — Node has no native mmap API and native addons break SEA bundling**: True mmap not viable: Node core has no \`fs.mmap\` (Buffer is always heap-backed); native addons (\`mmap-io\` etc.) break two hard project rules — no runtime dependencies and must bundle into Node SEA binary (esbuild can't bundle \`.node\` addons). \`Bun.mmap\` would have worked but project migrated off Bun. Alternative \`pread\` via \`fs.read(fd, buf, 0, len, pos)\` saves ~100MB JS heap for base binary (bytes stay in OS page cache) but cannot help intermediate hops — those buffers must stay in memory for in-memory chaining. Also: one big sequential \`readFile\` is faster than many small \`pread\`s. pread deferred as optional future optimization for single-patch case only. + + +* **CLI source-tier: bundle-sources first, print-sources deferred pending source enumeration API**: Chose to implement \`bundle-sources\` first over \`print-sources\` because \`SourceBundleWriter.writeObject()\` in \`@sentry/symbolic@13.4.0\` provides exactly the right shape (callback provider reads from disk). \`print-sources\` deferred because \`ObjectFile\` has no \`sourceFiles()\` enumeration method in 13.4.0 — blocked on Dav1dde shipping source enumeration in a future \`@sentry/symbolic\` release. WASM debug-files track is otherwise complete: symbolic PRs #988–#993 merged, \`@sentry/symbolic@13.4.0\` published, CLI PR #1124 merged. + * **Migrated to node**: Migrated to Node.js (from Bun). Migration complete as of fossilize 0.7.0 update. Stack: pnpm, vitest, tsx, Node SEA via fossilize. PRs #1017, #1018, #1019 on getsentry/cli. Benchmarks vs v0.34.0 (Bun): download 32MB→30MB, startup ~1s both, shell completions 180ms→150ms. All macOS binaries signed+notarized. fossilize handles SEA builds with V8 code cache on linux+macOS, strips debug symbols automatically. \`bun.lock\` deleted, \`vitest.config.ts\` added, all test files migrated to vitest. \`script/build.ts\` uses fossilize (\`--no-bundle\`) with esbuild for bundling — does NOT use \`Bun.build({ compile: true })\`. @@ -101,17 +116,23 @@ ### Gotcha + +* **acquireLock ENOENT: parent directory may not exist — must mkdir before writeFileSync**: Trap: \`acquireLock(lockPath)\` calls \`writeFileSync(lockPath, pid, { flag: 'wx' })\` — looks safe because the lock path is derived from the install path. But the parent directory (e.g. \`~/.sentry/bin\` or a stale \`/tmp/sentry-test-install/\`) may not exist, causing ENOENT (Sentry issue CLI-1E1). Fix: call \`mkdirSync(dirname(lockPath), { recursive: true, mode: 0o755 })\` BEFORE the try block in \`acquireLock\` (\`src/lib/binary.ts\`). CRITICAL: keep mkdir OUTSIDE the try block — if inside, mkdir's EEXIST error routes into \`handleExistingLock\`, obscuring root cause (though infinite recursion claim in comment is inaccurate: actual path is EEXIST→handleExistingLock→readFileSync→ENOTDIR, single throw). The directory creation is idempotent — \`recursive: true\` is a no-op if it already exists. + * **batch-queue.ts: 404 from upstream treated as transient — provider never disabled**: Trap: \`BatchProvider.submit()\` returns \`null\` for any non-401/403 HTTP error, including 404. \`submitBatch()\` treats \`null\` as transient and falls back — no disable happens. For providers that don't implement \`/v1/messages/batches\` (e.g. MiniMax), this causes a wasted HTTP round-trip every 30s forever. Fix: add \`"not-found"\` return value for 404 in both Anthropic and OpenAI submit methods. In \`submitBatch()\`, handle \`"not-found"\` with provider-level disable: add \`disabledBatchProviders: Set\\` (keyed by provider name), persist to \`kv\_meta\` via \`setKV()\`, restore on startup. Add fast-path bypass in both \`flush()\` and \`prompt()\`. Provider-level (not per-session) because the URL is baked in at construction — one provider per process. \`groupKey()\` = \`authFingerprint(cred)|providerID\`; per-credential disable was removed in favor of per-session historically. * **check:fragments only validates file existence — not subcommand coverage depth**: RESOLVED in PR #1024. \`script/check-fragments.ts\` now has Check 5: for each route with >1 command, verifies the fragment mentions each subcommand via a heading (outside fenced code blocks) or \`sentry \ \\` code reference. Default commands (e.g., \`local serve\`) are handled — if the fragment contains \`sentry \\` bare, the default command is considered covered. Default commands detected from route map \`defaultCommand\` field. Fenced code block content stripped before heading scan to avoid bash-comment false positives. Warnings (not errors) by default; \`--strict\` flag makes them errors. + +* **check.ts hasId() uses !== null but ObjectFile.codeId is string|undefined — null guard mismatch**: Trap: \`check.ts\` \`hasId()\` guards \`o.codeId !== null && o.codeId.length > 0\`. Looks correct because old \`DifObjectInfo.codeId\` was \`string | null\`. But \`ObjectFile.codeId\` in \`@sentry/symbolic\` 13.4.0 is \`string | undefined\` — the getter returns \`undefined\` (not \`null\`) when Rust returns \`None\`. If \`DifObjectInfo.codeId\` is mapped as \`string | null\` (via \`?? null\`), the \`!== null\` guard works. If mapped as \`string | undefined\` (via \`?? undefined\`), the guard silently passes \`undefined\` through. Fix: ensure \`parseDebugFile\` maps \`codeId\` to \`string | null\` (using \`obj.codeId ?? null\`) so \`check.ts\`'s existing \`!== null\` guard remains correct without touching \`check.ts\`. + * **dashboard revisions/restore and issue events subcommands are undocumented in fragment files**: RESOLVED in PR #1024. \`docs/src/fragments/commands/dashboard.md\` now documents \`revisions\` and \`restore\`. \`docs/src/fragments/commands/issue.md\` now documents \`events\` and \`@latest\`/\`@most\_frequent\` selectors. \`docs/src/fragments/commands/cli.md\` now documents \`defaults\` and \`import\`. \`check:fragments\` (Check 5) now validates subcommand coverage within fragment files — not just file existence. When adding new subcommands, always update the corresponding fragment in \`docs/src/fragments/commands/\` AND run \`pnpm run check:fragments\` to verify coverage. -* **delta-upgrade intermediate files: always alternate .patching.a/.b — never write to source path**: Trap: writing patch output to the same path as input looks like a simple in-place update. Fix: \`applyPatchesSequentially()\` alternates between \`${destPath}.patching.a\` and \`${destPath}.patching.b\` because the old binary is mmap'd for reading — writing to the source path truncates it and corrupts output. Single-patch chains apply old→dest directly (safe, different paths). \`cleanupIntermediates()\` called in \`finally\` block removes both intermediates via \`unlinkSync\` (ignoring errors) — always runs even on failure. This is a standing directive: 'Always clean up intermediate files, even on failure' and 'never target the same path.' +* **delta-upgrade intermediate files: always alternate .patching.a/.b — never write to source path**: delta-upgrade intermediate files: always alternate .patching.a/.b — never write to source path: Trap: writing patch output to the same path as input looks like a simple in-place update. Fix: \`applyPatchesSequentially()\` alternated between \`${destPath}.patching.a\` and \`${destPath}.patching.b\` because the old binary is mmap'd for reading — writing to the source path truncates it and corrupts output. \*\*Superseded by in-memory chaining\*\*: \`applyPatchChainInMemory(oldPath, patches\[], destPath)\` keeps intermediate buffers in memory (no disk writes, no intermediate files). \`applyPatchToMemory(oldFile, patchData)→Uint8Array\` for intermediate hops; \`applyPatchToFile(oldPath, patchData, destPath)→SHA-256\` for final hop. \`loadOldBinary\` still copies running binary to temp file (COPYFILE\_FICLONE) — called once per chain, not per-hop. Peak memory: ~200MB for multi-hop chains (acceptable). \`cleanupIntermediates()\` and \`PatchResources\` removed. \`applyPatchChain()\` does NOT set executable permissions — caller (\`downloadBinaryToTemp\`) sets \`chmod 0o755\`. * **DEVELOPMENT.md hand-written prose is not covered by any staleness check**: RESOLVED in PR #1024. \`DEVELOPMENT.md\` hand-written prose is now wrapped in \`GENERATED:START/END\` markers: \`dev-prereq\` (lines 4-7) and \`build-toolchain\` (lines 91-97). These sections are now auto-generated from \`package.json\` by \`generate-docs-sections.ts\` and validated by \`check:docs-sections --check\`. The only remaining non-generated prose in \`DEVELOPMENT.md\` is the OAuth app setup instructions and architecture description — these don't reference toolchain versions. \`generate-docs-sections.ts\` no longer contains any Bun references; \`extractPnpmVersion()\` and \`extractNodeVersion()\` throw on mismatch. @@ -125,6 +146,9 @@ * **generate-docs-sections.ts still references Bun — extractBunVersion() silently falls back to hardcoded '1.3'**: RESOLVED in PR #1024. \`generate-docs-sections.ts\` previously had \`BUN\_VERSION\_RE\` and \`extractBunVersion()\` that silently returned hardcoded \`'1.3'\` when \`packageManager\` was \`pnpm@10.11.0\`. Fixed: replaced with \`extractPnpmVersion()\` and \`extractNodeVersion()\` that \*\*throw on mismatch\*\* instead of silently falling back. \`generateDevPrereq()\`, \`generateDevPrereqContributing()\`, \`generateLibraryPrereq()\` now reference Node.js + pnpm. \`DEVELOPMENT.md\` lines 5 and 91 are now wrapped in \`GENERATED:START/END\` markers so they can't drift again. No Bun references remain in the script. + +* **getCurlInstallPaths trusts stale stored install path — must guard with existsSync(dirname)**: Trap: \`getCurlInstallPaths()\` in \`src/lib/upgrade.ts\` reads the stored install path from SQLite and uses it directly — looks correct because the path was valid at install time. But macOS cleans \`/tmp\` on reboot, and users may delete test install dirs, leaving a stale DB entry. Fix: guard the stored-path branch with \`existsSync(dirname(stored.path))\` before trusting it; fall back to \`process.execPath\` startsWith-match against \`KNOWN\_CURL\_DIRS\` (\`\['.local/bin','bin','.sentry/bin']\`), then \`~/.sentry/bin\` default. Conservative: only add the guard — do NOT prefer \`execPath\` over stored path (breaks npm→nightly migration flow). NFS edge case is self-resolving: if binary runs from NFS mount, mount must be active so \`existsSync\` passes. + * **GitHub CI skips pull\_request workflows entirely when PR has merge conflicts**: Trap: missing CI jobs on a PR look like a workflow trigger bug or branch filter issue — easy to spend time investigating ci.yml triggers. Fix: check \`mergeable\`/\`mergeStateStatus\` first. When a PR is \`CONFLICTING\` (GitHub cannot compute the merge ref), ALL \`pull\_request\`-triggered workflows are silently skipped — only \`pull\_request\_target\` and CodeQL/external checks still run. Confirmed on sentry-cli (TypeScript) PR #1123: full Build/lint/test suite absent because \`mergeStateStatus: DIRTY\`. Resolution: rebase or resolve conflicts, then CI triggers normally. @@ -134,6 +158,9 @@ * **issue list sort default test coverage gap: tests always pass sort explicitly**: Trap: \`list.test.ts\` passes \`sort\` explicitly in every \`func.call(...)\` invocation (\`sort: 'date'\` or \`sort: 'recommended'\`). No end-to-end test omits \`sort\` and asserts the API received the correct default (\`recommended\` on SaaS, \`date\` on self-hosted). This means \`defaultIssueSort()\` logic is not covered by integration tests — only unit-tested via \`\_\_testing\` exports. When adding host-dependent flag defaults, always add a test that omits the flag and verifies the resolved default reaches the API call. + +* **loadOldBinary: new Uint8Array(readFile()) double-allocates — return Buffer directly**: Trap: \`new Uint8Array(await readFile(tempCopy))\` looks like a safe type conversion. But \`readFile()\` already returns a \`Buffer\` which IS a \`Uint8Array\` — wrapping it in \`new Uint8Array()\` copies the entire ~100MB binary, causing a transient double-allocation peak. Fix: return the \`Buffer\` directly from \`loadOldBinary\` with no wrapper. Zero tradeoff — Buffer is already a Uint8Array subclass and works everywhere Uint8Array is expected. + * **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, Bun's fetch dispatcher 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); ... })\`. @@ -159,7 +186,7 @@ * **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;\` -* **streamDecompressToFile: never emit 'drain' on ENOSPC — race drain against error to avoid hang**: Trap: \`writer.write()\` returning false normally means wait for 'drain' before continuing. But on ENOSPC/EIO, the error fires while the buffer is full — 'drain' never fires, causing a permanent hang. Fix: \`streamDecompressToFile()\` races 'drain' against 'error' listeners so whichever fires first unblocks the loop. This is a standing directive: 'never emit drain' when an I/O failure occurs while the buffer is full. Same pattern applies to \`downloadStableToPath()\` which uses \`arrayBuffer()\` + \`writeFile()\` (not streaming) as a fallback due to the Bun event-loop GC bug (https://github.com/oven-sh/bun/issues/13237). +* **streamDecompressToFile: never emit 'drain' on ENOSPC — race drain against error to avoid hang**: streamDecompressToFile: never emit 'drain' on ENOSPC — race drain against error to avoid hang: Trap: \`writer.write()\` returning false normally means wait for 'drain' before continuing. But on ENOSPC/EIO, the error fires while the buffer is full — 'drain' never fires, causing a permanent hang. Fix: \`streamDecompressToFile()\` races 'drain' against 'error' listeners so whichever fires first unblocks the loop; \`onDrain\` removes \`onError\` listener and vice versa to avoid \`MaxListenersExceededWarning\`. Same pattern applies to \`downloadStableToPath()\` which uses \`arrayBuffer()\` + \`writeFile()\` (not streaming) as a fallback due to the Bun event-loop GC bug (https://github.com/oven-sh/bun/issues/13237). Package managers always need network to fetch and install packages — offline fallback is only supported for \`method === 'curl'\` installs. * **strip fails on Node SEA binaries — must strip BEFORE fossilize injection**: Strip debug symbols must happen BEFORE fossilize SEA injection. Trap: \`strip --strip-unneeded\` on a plain Node binary saves ~17 MiB and still runs — looks like it should work on the final SEA binary too. But after postject injects the SEA blob, \`strip\` fails: 'section .text can't be allocated in segment 2'. Fix: as of fossilize 0.7.0, stripping is built into fossilize itself — it strips the copied binary (already unsigned for macOS/Windows) BEFORE calling postject. Cross-strip from Linux to macOS silently fails (caught); native macOS runners strip correctly with \`strip -x\`. Windows skipped (no debug symbols). \`stripCachedNodeBinaries()\` was removed from \`script/build.ts\` in fossilize 0.7.0 update — fossilize handles it natively. @@ -176,8 +203,14 @@ * **wasm-bindgen: exporting a class named \`Object\` shadows JS global Object — use js\_name**: Trap: naming a \`#\[wasm\_bindgen]\` Rust struct \`Object\` looks fine in Rust, and \`wasm-pack test --node\` passes because it never loads the \`--target web\` glue via \`initSync\`. Fix: the generated \`symbolic.js\` emits \`export class Object\` which shadows the JS global \`Object\` throughout the module. \`initSync\` calls \`Object.getPrototypeOf(module)\` — resolves to the exported class, throws \`TypeError: Object.getPrototypeOf is not a function\`. Also breaks \`Object.create\` in \`objects()\`. Consumer doing \`import \* as symbolic from '@sentry/symbolic'; symbolic.initSync({ module })\` gets this error. Fix: add \`#\[wasm\_bindgen(js\_name = "ObjectFile")]\` on the struct and \`#\[wasm\_bindgen(js\_class = "ObjectFile")]\` on the impl block. Only artifact smoke tests (loading actual \`symbolic.js\` via \`initSync\`) catch this — \`wasm-pack test --node\` misses it entirely. + +* **wasm-pack test --node never catches js\_name/js\_class binding bugs like Object shadowing**: Trap: \`wasm-pack test --node\` looks like a complete test of the WASM package — it runs Rust tests compiled to WASM. But it builds its own JS glue and never loads the \`--target web\` artifact. So \`export class Object\` shadowing the JS global \`Object\` passes all wasm-pack tests. Fix: use the two-layer approach — (1) \`wasm\_bindgen\_test\` + \`wasm-pack test\` for bulk behavior, (2) artifact smoke test that does \`npm pack\` → install into temp dir → \`import "@sentry/symbolic"\` → assert API loads. The smoke test catches packaging regressions that wasm-pack misses. Fix for Object shadowing: \`#\[wasm\_bindgen(js\_name = "ObjectFile")]\` + \`#\[wasm\_bindgen(js\_class = "ObjectFile")]\`; Rust struct name \`Object\` unchanged. + + +* **wasm-pack test never loads the published package — builds its own glue instead**: Trap: \`wasm-pack test\` looks like the right way to test the published \`@sentry/symbolic\` package (it builds WASM and runs tests). Fix: \`wasm-pack test\` builds its own glue (\`--target web\`, \`symbolic.js\`, \`initSync\`) and never loads what the package actually ships via \`exports\`/\`files\`. Testing the published package requires loading it through the actual package entry points, not wasm-pack's test harness. Confirmed by Burak Yigit Kaya on Jun 22 2026. + -* **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\`. +* **Whole-buffer matchAll slower than split+test when aggregated over many files**: Grep/scan traps in \`src/lib/scan/\`: (1) Literal prefilter is FILE-LEVEL gate; per-line verify breaks cross-newline patterns. (2) \`hasTopLevelAlternation\`+\`skipGroup\` must call \`skipCharacterClass\`. (3) Wake-latch race: use latched \`pendingWake\` flag. (4) \`mapFilesConcurrent\` filters \`null\` but NOT \`\[]\` — return \`null\` for no-op files. (5) \`collectGlob\`/\`collectGrep\` must NOT forward \`maxResults\` to iterator. Worker pool: lazy singleton, size \`min(8, max(2, availableParallelism()))\`. Matches encoded as \`Uint32Array\` quads (~40% faster). \`new Worker(new URL(...))\` HANGS in SEA binaries — use Blob+URL.createObjectURL. \`ref()\`/\`unref()\` idempotent — only unref when \`inflight\` drops to 0. Disable via \`SENTRY\_SCAN\_DISABLE\_WORKERS=1\`. * **worktree node\_modules symlink causes esbuild host/binary version mismatch**: Trap: when running multiple agent worktrees in parallel, \`node\_modules\` may be a symlink to a sibling worktree's install — looks fine until esbuild runs. Error: 'Host version X does not match binary version Y' crashes all generate scripts and typecheck. Fix: remove the symlink and run \`pnpm install\` in the worktree root to get an independent \`node\_modules\`. The symlink is created by mutation-test workflows that intentionally share \`node\_modules\` for speed — but only safe when both worktrees are on the same branch/lockfile. @@ -187,6 +220,15 @@ ### Pattern + +* **@sentry/symbolic 13.4.0 API: Archive/ObjectFile class contract and WASM memory management**: \`@sentry/symbolic\` 13.4.0 exports \`Archive\`, \`ObjectFile\`, \`FileEntry\`, \`SourceBundleWriter\`, \`SourceFileDescriptor\` + \`initSync\`/default \`\_\_wbg\_init\`. Key contracts: \`Archive.peek(data)\` returns \`string | undefined\` (never \`null\`, never \`"unknown"\`) — consuming code must use \`?? "unknown"\` fallback. \`ObjectFile.codeId\` is \`string | undefined\` (not \`null\`). All 5 classes use \`FinalizationRegistry\` for automatic WASM GC — explicit \`.free()\` / \`\[Symbol.dispose]\()\` still recommended for deterministic cleanup. \`initSync\` guards \`if (wasm !== undefined) return wasm\` — safe to call multiple times. \`ObjectFile\` was renamed from \`Object\` in 13.4.0 (PR #993) — old name shadowed JS global \`Object\` and broke \`initSync\` in \`--target web\` glue. \`@sentry/symbolic/symbolic\_bg.wasm\` subpath is marked \`external\` in both \`script/build.ts\` and \`script/bundle.ts\` esbuild configs — bundler never inlines the WASM blob. + + +* **@sentry/symbolic WASM build: always have latest build + initSync pattern**: When working with \`@sentry/symbolic\` WASM in the CLI: (1) always have the latest build before testing — recompile the WASM file if changed; (2) WASM module is loaded via \`initSync({ module })\` passing bytes directly; (3) the module statically imports the generated JS glue (\`symbolic.js\`). The 3-path fallback loader: (1) SEA: \`node:sea.getRawAsset(DIF\_WASM\_ASSET\_KEY)\`, (2) npm bundle: sibling \`dist/vendor/symbolic\_bg.wasm\` via \`import.meta.url\` + \`existsSync\`, (3) dev: \`require.resolve\` from installed package. \`@sentry/symbolic@13.4.0\` introduced \`Archive\` class with \`static peek(data: Uint8Array): string | undefined\` and \`ObjectFile\` class replacing old \`parse\_debug\_file\`/\`peek\_format\` free functions. + + +* **@sentry/symbolic wasm-bindgen instances: auto-freed via FinalizationRegistry — no explicit .free() needed**: wasm-bindgen instances (\`Archive\`, \`ObjectFile\`) created in \`src/lib/dif/index.ts\` are never explicitly \`.free()\`'d — this is acceptable. \`symbolic.js\` registers \`Archive\` in its constructor (\`:45\`) and each \`ObjectFile\` in \`\_\_wrap\` (\`:136\`) with a \`FinalizationRegistry\` (\`:633-647\`) that calls \`.free()\` on GC. For a one-shot CLI that exits after parsing one file, no manual disposal is needed. Only the \`.wasm\` subpath (\`@sentry/symbolic/symbolic\_bg.wasm\`) is marked \`external\` in both esbuild configs (\`build.ts:137\`, \`bundle.ts:226\`); the JS glue exporting \`Archive\` is bundled. + * **clack-utils.ts filename preserved intentionally — rename deferred to next cleanup PR**: \`src/lib/init/clack-utils.ts\` filename kept (not renamed to \`wizard-utils.ts\`) to keep PR 4 diff focused on clack removal. No clack references remain in the file. \`WizardCancelledError\` lives here. \`abortIfCancelled\()\` return type uses \`Exclude\\` to narrow union types. \`FEATURE\_DISPLAY\_ORDER\` and \`CANONICAL\_STEP\_ORDER\` (12 steps) also defined here. Rename is intentionally deferred. @@ -224,7 +266,7 @@ * **sensitive argv flags must never reach telemetry — redactArgv() in cli.ts**: \`SENSITIVE\_ARGV\_FLAGS = new Set(\['token', 'auth-token'])\` in \`src/cli.ts\`. \`redactArgv()\` replaces values of these flags with \`\[REDACTED]\` before any telemetry call. This is an absolute invariant — never pass raw \`process.argv\` to telemetry without running through \`redactArgv()\` first. -* **Sentry SDK tree-shaking patches must be regenerated via bun patch workflow**: Sentry SDK tree-shaking via bun patch: \`patchedDependencies\` in \`package.json\` strips unused exports from \`@sentry/core\` and \`@sentry/node-core\`. Non-light root of \`@sentry/node-core\` pulls uninstalled \`@opentelemetry/instrumentation\` — \*\*always import from \`@sentry/node-core/light\`\*\* (subpaths: \`.\`, \`./light\`, \`./light/otlp\`, \`./init\`, \`./loader\`, \`./import\`). No supported import for \`HttpsProxyAgent\`. Bumping SDK: remove old patches, \`rm -rf ~/.bun/install/cache/@sentry\`, \`bun install\`, \`bun patch @sentry/core\`, edit, \`bun patch --commit\`; repeat for node-core. Preserved: \`\_INTERNAL\_safeUnref\`, \`\_INTERNAL\_safeDateNow\`, \`nodeRuntimeMetricsIntegration\`. Before stripping any core export, grep \`node-core/build/{cjs,esm}/light/sdk.js\` for runtime usage (e.g. \`spanStreamingIntegration\` when \`traceLifecycle === 'stream'\`). Remove \`.bun-tag-\*\` hunks from generated patches. Manual \`git diff\` patches fail. +* **Sentry SDK tree-shaking patches must be regenerated via bun patch workflow**: Sentry SDK tree-shaking via pnpm patch: \`patchedDependencies\` in \`package.json\` under \`pnpm\` config block strips unused exports from \`@sentry/core\` and \`@sentry/node-core\`. Always import from \`@sentry/node-core/light\`. Bumping SDK: remove old patches, \`pnpm patch @sentry/core\`, edit, \`pnpm patch-commit\`; repeat for node-core. \`check:patches\` validates version alignment AND content. \`@stricli/core\` patch targets \`dist/index.js\` (ESM) only — \`dist/index.cjs\` intentionally unpatched. When bumping \`@stricli/core\`, patch line numbers shift — always verify offsets. KNOWN: Stricli \`-H\` removal patch is fragile — any Stricli version bump will break it; upstream fix will NOT be accepted. Chunk-upload wire format: zstd → \`Content-Encoding: zstd\` + \`file\` field; gzip → \`file\_gzip\` field, NO \`Content-Encoding\` header (servers reject \`Content-Encoding: gzip\` + \`file\_gzip\` with 400); plain → \`file\` field, no encoding header. NEVER emit \`Content-Encoding: gzip\` alongside \`file\_gzip\`. * **sentry-cli debug-files: @sentry/symbolic usage locations for API migration**: \`@sentry/symbolic\` consumed in sentry-cli (TypeScript) at \`src/lib/dif/index.ts\`: imports \`initSync\`, \`parse\_debug\_file as wasmParseDebugFile\`, \`peek\_format as wasmPeekFormat\`. \`DIF\_WASM\_ASSET\_KEY = 'dist-build/symbolic\_bg.wasm'\` (SEA asset key). \`SYMBOLIC\_WASM\_SUBPATH = '@sentry/symbolic/symbolic\_bg.wasm'\` (dev resolution, marked external in esbuild). 3-path WASM loader: SEA \`node:sea.getRawAsset\`, sibling \`./vendor/symbolic\_bg.wasm\`, dev \`\_require.resolve(SYMBOLIC\_WASM\_SUBPATH)\`. Public API: \`parseDebugFile(data: Uint8Array): DifArchiveInfo\`, \`peekFormat(data: Uint8Array): string\`. Internal \`RawArchiveInfo\` (snake\_case Rust) mapped to camelCase \`DifArchiveInfo\`. Migration to \`Archive\`/\`ObjectFile\` class-based API on branch \`byk/feat/debug-files-new-symbolic-api\` (commit \`3052a6e2d\`). Consumer: \`src/commands/debug-files/check.ts\` imports \`DifArchiveInfo\`, \`parseDebugFile\`. @@ -233,7 +275,7 @@ * **setup.ts bestEffort() wrapper: post-install steps must never crash setup**: \`src/commands/cli/setup.ts\` \`bestEffort(stepName, fn)\` wraps non-essential post-install steps (recording install info, shell completions, agent skills) in try/catch. On failure: calls \`warn(stepName, error)\` + \`captureException(error, { level: 'warning', tags: { 'setup.step': stepName } })\`. These steps must NEVER crash setup — enforced by \`bestEffort()\`. \`runConfigurationSteps()\` applies \`bestEffort()\` independently to all 4 steps. Install dir priority: (1) \`$SENTRY\_INSTALL\_DIR\`, (2) \`~/.local/bin\` if exists+in PATH, (3) \`~/bin\` if exists+in PATH, (4) \`~/.sentry/bin\` fallback. Welcome message only on fresh install (not upgrades). -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Pagination infrastructure + org flag injection: Bidirectional pagination via cursor stack in \`src/lib/db/pagination.ts\`. \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/first/last) to \`{cursor, direction}\`. \`advancePaginationState\` manages stack — back-then-forward truncates stale entries. Critical: \`resolveCursor()\` must be called INSIDE \`org-all\` override closures, not before \`dispatchOrgScopedList\`. \`issue list --limit\` is global total: \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus. \`trimWithProjectGuarantee\` ensures ≥1 issue per project. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. \`sort\` flag is resolved once in \`func()\` before dispatch — never re-derived by infra. \`handleOrgAllIssues\` returns server order (no client-side sort). \`isMultiProject\` guard gates client-side sort at list.ts:1144-1148. +* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Pagination infrastructure + org flag injection: Bidirectional pagination via cursor stack in \`src/lib/db/pagination.ts\`. \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/first/last) to \`{cursor, direction}\`. \`advancePaginationState\` manages stack — back-then-forward truncates stale entries. Critical: \`resolveCursor()\` must be called INSIDE \`org-all\` override closures, not before \`dispatchOrgScopedList\`. \`issue list --limit\` is global total: \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus. \`trimWithProjectGuarantee\` ensures ≥1 issue per project. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. Hidden global \`--org\`/\`--project\` flags: defined in \`GLOBAL\_FLAGS\`, \`mergeGlobalFlags()\` injects hidden flag shapes, \`applyOrgProjectFlags()\` writes to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` before auth guard. \`applyGroupLimitAutoDefault\` helper keeps \`buildCommand\` under Biome's cognitive complexity limit of 15. * **Stricli optional flags with runtime-resolved defaults: omit static default, resolve in func()**: When a flag's default depends on runtime state (e.g., host type), Stricli static defaults cannot be used. Pattern: mark flag \`optional: true\` with no \`default\` in the flag definition; in \`func()\`, immediately resolve: \`const flags = { ...rawFlags, sort: rawFlags.sort ?? defaultIssueSort() }\`. All downstream code receives the concrete resolved type. \`ListFlagsInput\` uses \`Omit\ & { readonly sort?: SortValue }\` as the \`func()\` parameter type; \`ListFlags\` (with required sort) is used everywhere else. This avoids Stricli's static-default limitation while keeping type safety. @@ -242,7 +284,7 @@ * **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). -* **Testing Stricli command func() bodies via spyOn mocking**: Testing Stricli command func() bodies: (1) \`const func = await cmd.loader(); func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. \`loader()\` return type union causes \`.call()\` LSP false-positives that pass \`tsc --noEmit\`. (2) When API functions are renamed, update both spy target AND mock return shape. (3) \`normalizeSlug\` replaces \`\_\`→\`-\` but does NOT lowercase. (4) Bun \`mockFetch()\` replaces \`globalThis.fetch\` — use one unified mock dispatching by URL. (5) \`mock.module()\` pollutes module registry for ALL subsequent files — put in \`test/isolated/\` and run via \`test:isolated\`. (6) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. +* **Testing Stricli command func() bodies via spyOn mocking**: Testing Stricli command func() bodies: \`const func = await cmd.loader(); func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. \`.call()\` LSP false-positives pass \`tsc --noEmit\`. When API functions are renamed, update both spy target AND mock return shape. \`normalizeSlug\` replaces \`\_\`→\`-\` but does NOT lowercase. Vitest: use \`vi.spyOn\` / mock fetch via \`globalThis.fetch\`. \`mock.module()\` pollutes module registry — put in \`test/isolated/\`. ALL test files MUST import from \`'vitest'\` — NEVER \`'bun:test'\`. Variadic flag pattern: \`kind: 'parsed', parse: String, variadic: true, optional: true\`. Aliases: \`aliases: { s: 'scope' }\` — place INSIDE \`parameters\` (sibling to \`flags\`), NOT at top-level of \`buildCommand\` options (causes TS2353). \`-s\` is free in \`auth/login.ts\`. Biome formatter enforces specific line-break style for \`func.call(ctx, { ... }, arg)\` — run \`biome format --write\` after generating test files. Bun→Node.js: \`Bun.spawn\`→\`node:child\_process\`, \`bun:sqlite\`→\`node:sqlite\`, \`bun run\`→\`pnpm run\`/\`tsx\`, \`Bun.randomUUIDv7()\`→\`uuidv7\`. \`new Worker(new URL(...))\` HANGS in SEA — use Blob+URL.createObjectURL. * **wizard-runner.ts: large shared context via initialState, not inputData — D1 row size limit**: In \`wizard-runner.ts\`, large shared context (\`dirListing\`, \`fileCache\`, \`existingSentry\`) travels via \`initialState\` (not \`inputData\`) to avoid D1 per-row size overflow (see getsentry/cli-init-api#98). \`MAX\_RESUME\_RETRIES = 3\`, \`RETRY\_BACKOFF\_MS = \[2000, 4000, 8000]\`. \`resumeWithRetry()\` handles stale-step recovery via \`tryRecoverCurrentRunState()\` when \`isStepAlreadyAdvancedError()\` detects 'was not suspended' 500. @@ -255,6 +297,9 @@ * **Always address bot review comments (Cursor Bugbot, Sentry bot) by applying fixes before merging**: When a PR has review comments, the user expects every comment to be investigated and resolved before merging — including bot comments (Cursor Bugbot, Seer) and human reviewer comments. This includes: removing flagged unnecessary code/comments, adding missing CI steps identified by reviewers, and replying to each comment after fixing. The user also investigates ambiguous reviewer references (e.g., 'image.yml' when no such file exists) to find the actual intended target. Only after all comments are addressed and CI passes does the user proceed to merge. + +* **Always branch off main for focused fixes, never commit unrelated changes to feature branches**: When starting a focused fix or chore (security patch, bug fix, etc.), the user consistently creates a fresh branch off origin/main rather than committing to an existing feature branch. They explicitly scope the branch to only the relevant changes, excluding unrelated modifications (e.g., .lore.md updates, auto-regenerated files, OTel work). When the working tree has uncommitted changes on a feature branch, they stash, back up, or otherwise isolate those changes before branching from main. The new branch name reflects the specific fix (e.g., \`chore/security-deps-bump\`, \`byk/fix/upgrade-missing-install-dir\`). Follow this pattern by always proposing a clean branch from main for any fix that is independent of ongoing feature work. + * **Always clarify that the repo uses plain git (not jj) when jj commands fail**: When a jj command fails with 'no jj repo in .', the user consistently clarifies that the repo is a plain git repo and that jj's 'never fails on conflict' behavior is being referenced conceptually — meaning conflicts should be recorded/resolved rather than aborting operations. The agent should: (1) fall back to git commands immediately without retrying jj, (2) handle merge conflicts by stashing, pulling, and resolving (e.g., \`git checkout --theirs\` for files like \`.lore.md\`), and (3) not attempt \`jj git init\` or any jj initialization. This pattern appears at the start of every build session. @@ -264,9 +309,15 @@ * **Always conduct adversarial read-only code review before approving PRs**: When reviewing a PR, the user runs a structured adversarial review with explicit numbered probes (P1, P2, P3...) and severity-tagged findings (SHOULD-FIX, NIT). The review is strictly read-only — no file modifications, no branch switches. Each probe targets a specific correctness concern (e.g., flag propagation, guard completeness, test regression validity, timing/ordering). The review produces a final verdict (APPROVE/BLOCK) with itemized findings. When acting as reviewer, never modify files, never skip probes even if early ones pass, and always produce a non-empty report with explicit pass/fail status per probe. + +* **Always conduct adversarial read-only PR reviews as a distinct, structured task**: When the user issues a PR review task, they frame it explicitly as adversarial and read-only: no file modifications, no branch switches, no working tree changes. Reviews must always produce a non-empty report. The user expects the reviewer to deeply understand the PR's intent, enumerate specific code locations and logic paths, identify edge cases and failure modes (e.g., infinite recursion, stale paths, error misrouting), and reference related context (Sentry issues, crash paths, bot comments). Reviews cover correctness, error handling, test coverage, and CI status. Never skip or defer findings — even partial findings must be reported. + * **Always conduct thorough PR reviews with severity-classified findings**: PR review standards: (1) Compare branch vs main first (\`git log main..origin/\\`, \`git diff --stat\`). (2) Verify every PR description claim against actual source files at specific line numbers — never trust PR metadata. (3) Classify findings as BLOCKING vs NON-BLOCKING with file paths and line numbers. (4) Flag LLM-generated planning artifacts (e.g., DOCS-AUDIT.md) as blocking violations of repo conventions. (5) Investigate root causes — check bundle output, trace esbuild variable renaming, identify silent regressions. (6) Run relevant check scripts and grep codebase directly rather than reasoning from PR metadata. + +* **Always confirm and send notifications to David (Dav1dde) when PR milestones are reached**: When a significant PR milestone is reached (merge, readiness for handoff, or status update), the user explicitly confirms sending a notification to David (Dav1dde/dav1d) via Beeper chat 557079. The user gives a short confirmation ('yup', explicit instruction) and expects the assistant to open the Beeper chat and send the message. This applies to: merged PRs that unblock David's work, status summaries of completed tracks, and follow-up replies to David's messages. The assistant should draft the message, confirm with the user, then send it via Beeper /open/557079. + * **Always create a dedicated branch when updating fossilize versions**: When a new version of fossilize is released, always create a branch named \`chore/fossilize-{version}\` tracking origin/main, update the dependency, remove any functionality now handled natively by fossilize (e.g., \`stripCachedNodeBinaries()\` removed in 0.7.0), verify the build succeeds, then commit with \`chore: update fossilize to X.Y.Z\`. Follow this exact pattern: branch → update dep → remove superseded code → build verify → commit → PR. @@ -282,6 +333,9 @@ * **Always embed design rationale as inline comments on critical invariants**: When code enforces a non-obvious invariant or safety constraint, the user adds a verbatim explanatory comment directly at the enforcement site explaining \*why\* the rule exists, not just what it does. Examples: 'Always clean up intermediate files, even on failure' at the finally block; 'never target the same path — writing to the source would truncate it and corrupt the output' at the alternating-file logic; 'Always check for Homebrew first — the stored install info may be stale'; 'never emit drain' with the race condition explanation. When writing or modifying code that enforces such invariants, always include a concise inline comment explaining the underlying reason (corruption risk, stale data, hang condition, etc.). + +* **Always encapsulate cleanup logic in finally blocks and keep it close to the resource that created it**: The user consistently enforces that cleanup of temporary resources (intermediate files, temp ZIPs, temp binaries) happens in \`finally\` blocks — guaranteeing cleanup even on failure. The cleanup code should live in the same function or module that created the resource, not delegated to callers. Examples: \`cleanupIntermediates()\` in \`applyPatchesSequentially()\`'s \`finally\`, temp ZIP deleted in \`finally\` in \`uploadSourcemaps\`, reader/writer cancellation in \`transformPatch\`'s \`finally\`. When refactoring, if a resource's lifecycle moves (e.g., intermediates eliminated by in-memory approach), the cleanup obligation moves with it or is removed entirely — never left as dead code. + * **Always enforce cleanup of intermediate files in finally blocks**: The user consistently emphasizes that intermediate/temporary files must always be cleaned up, even on failure. This is enforced via \`finally\` blocks rather than only in success paths. Specifically in patch chain application, intermediate files (e.g., \`${destPath}.patching.a\` and \`${destPath}.patching.b\`) must be deleted in a \`finally\` block so cleanup happens regardless of whether patching succeeds or throws. The user treats this as a non-negotiable design principle, often calling it out explicitly in code comments. When implementing any multi-step file operation that creates temporary files, always place cleanup logic in a \`finally\` block. @@ -303,6 +357,9 @@ * **Always follow maintainer/reviewer feedback precisely when updating PRs**: When a PR receives review comments from maintainers, the user expects all feedback to be addressed faithfully and completely before proceeding. This includes: refactoring delegation chains as suggested, changing return types per reviewer preference, removing unnecessary abstractions (e.g., SharedCursor), updating tests to match reviewer style (unwrap() instead of Result returns), and deferring out-of-scope items to future PRs. The user treats reviewer direction as authoritative and confirms the direction explicitly before starting implementation. Do not skip or partially address review comments — implement each one fully. + +* **Always follow red-green-refactor TDD cycle before implementing fixes**: When fixing bugs, the user consistently writes failing tests first (red), confirms they fail with the exact expected error, then applies the fix and verifies tests pass (green), before running typecheck and lint. This applies to all bug fixes, not just new features. The red test must reproduce the actual crash/bug symptom. After green, always run the broader test suite to catch regressions, then typecheck (exit 0) and lint (no fixes applied) before committing. Commit using conventional commit format (\`fix(scope): description\`). + * **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. @@ -348,11 +405,14 @@ * **Always reply to automated review bot comments after fixing the reported issue**: When an automated review tool (e.g., Cursor Bugbot, Seer Code Review) leaves an inline comment identifying a bug or issue, the user expects a reply to that specific comment after the fix is committed. The reply should reference the fix commit hash and briefly explain what was corrected and why the original code was problematic. This applies to inline PR review comments from bots, not just general PR-level feedback. Always post the reply using the comment's ID before considering the fix workflow complete. + +* **Always request adversarial, read-only code reviews with explicit scope boundaries**: When asking for code reviews, the user consistently specifies: (1) READ-ONLY mode — never modify files, switch branches, or alter the working tree; (2) a specific PR number and repo; (3) explicit focus areas (e.g., behavior parity, error handling, type correctness, test quality, edge cases); (4) the review must never be empty even if findings are partial. Always use \`gh pr diff\`, \`gh pr view\`, and \`git show\` to read PR content without checking out. Deliver a critical, objective report covering all specified focus areas. + * **Always request critical self-review of PRs before considering them ready to merge**: PR review workflow: (1) After completing a PR, always do a critical self-review (subagent for objectivity) before merge. Structure: severity levels (Critical/High/Medium/Low), actionable findings, cover correctness/safety/CI wiring/docs/changelog. (2) Address all bot/automated review comments (Cursor Bugbot, Sentry bot) as actionable work items — enumerate, investigate, fix, verify no regressions before merge. (3) Verify PR merge status (MERGED state, all checks passed, remote branch deleted) before starting follow-up work. (4) When reviewing PRs, verify factual claims (e.g. 'no existing code changed') with concrete tool-based evidence (git diff, file reads) — never accept at face value. (5) After requesting a merge, confirm merge commit OID, mergedAt, mergedBy, and final state. When blocked, explain why (ruleset vs. branch protection, self-approval restrictions). Post-merge: run verification tests, post results as PR comment, then merge. \`gh pr view --json merged\` field unavailable — use \`mergeCommit\`, \`mergedAt\`, \`mergedBy\`, \`state\`. -* **Always require a critical self-review before merging any PR**: Before merging any PR, the user consistently requires a thorough critical self-review of both the code and the PR description, explicitly preferring a subagent for objectivity/adversarial perspective. The subagent must be read-only (no file modifications, no branch switching). The review should cover: correctness of logic, accuracy of PR description claims, edge cases, regression test adequacy, and any stale/aspirational statements. The review must always produce a report (never empty). Issues found should be fixed before merging. This is a non-negotiable pre-merge gate, not optional. +* **Always require a critical self-review before merging any PR**: Before merging any PR, the user consistently requests a final, objective, read-only code review — typically delegated to a subagent for impartiality. The review covers: behavior parity, error handling, type correctness, memory/resource management, test passage (vitest run), typecheck (tsc --noEmit), absence of stale references (grep), and PR description accuracy. The verdict must explicitly classify findings by severity (Critical/High/Medium/Low) before a merge recommendation is given. This pattern applies to all PRs regardless of apparent simplicity. * **Always research technical approaches thoroughly before implementation**: When facing a significant technical decision or migration, the user consistently requests deep research into multiple approaches before writing any code. This includes: fetching specific upstream documentation/source files (e.g., BUILDING.md, configure.py), identifying concrete flags/options, estimating build times, and evaluating cross-compilation feasibility. The user wants tradeoffs between paths laid out explicitly. Only after research is complete does implementation begin. When presenting research, include specific flags, URLs, estimated costs (time/size), and platform constraints. @@ -390,6 +450,9 @@ * **Always track project state through structured session observations before acting**: The user consistently works across multiple sessions on long-running, multi-PR projects (e.g., symbolic-wasm, debug-files CLI). Before taking action, they review prior session context and orient around the current state of PRs, branches, and design decisions. They expect the assistant to fetch and summarize PR/code details proactively, record decisions and findings as structured observations, and maintain continuity across sessions. When resuming work, always re-establish context (branch state, open PRs, pending items) before proceeding with implementation or review tasks. + +* **Always use specific file paths when staging git commits, never \`git add -A\`**: When staging files for a commit, the user explicitly lists individual file paths rather than using \`git add -A\` or \`git add .\`. This avoids accidentally including untracked files (e.g., stray markdown files, \`.lore.md\` conflicts) or unintended changes. When a rebase or merge goes wrong due to bulk staging, the user aborts and re-applies changes manually with targeted \`git add \\` calls. Always recommend staging by explicit file path, and warn against broad staging commands that could sweep in unrelated files. + * **Always validate changes with local builds and tests before committing**: Before committing or finalizing a branch, the user consistently runs local validation: native tests (cargo test), target-specific builds (e.g., wasm32-unknown-unknown), fmt checks, and clippy. This applies especially after non-trivial changes like dependency swaps, cfg-gating, or CI workflow additions. The assistant should proactively run these checks (or prompt the user to) before declaring work done, and report pass/fail counts and timing. Only after all checks pass should files be committed. diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 2060a8319..24e37aad0 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -58,7 +58,7 @@ cli/ │ │ ├── code-mappings/# upload │ │ ├── dart-symbol-map/# upload │ │ ├── dashboard/ # add, create, delete, edit, list, restore, revisions, view -│ │ ├── debug-files/ # bundle-jvm, check +│ │ ├── debug-files/ # bundle-jvm, bundle-sources, check │ │ ├── event/ # list, send, view │ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view │ │ ├── local/ # run, serve diff --git a/docs/src/fragments/commands/debug-files.md b/docs/src/fragments/commands/debug-files.md index b79ffa9cd..641a6b54a 100644 --- a/docs/src/fragments/commands/debug-files.md +++ b/docs/src/fragments/commands/debug-files.md @@ -9,6 +9,10 @@ sentry debug-files check ./libexample.so sentry debug-files check MyApp.dSYM/Contents/Resources/DWARF/MyApp sentry debug-files check ./app.pdb --json +# Bundle a debug file's referenced source files (run on the build machine) +sentry debug-files bundle-sources ./libexample.so +sentry debug-files bundle-sources ./app.pdb --output ./app.src.zip + # Bundle JVM sources with a debug ID sentry debug-files bundle-jvm --output ./out --debug-id ./src @@ -21,11 +25,17 @@ sentry debug-files bundle-jvm --output ./out --debug-id --json ./src ## Important Notes -- `check` and `bundle-jvm` are **local-only** — they make no network requests. - Both parse object files in-process (Mach-O/dSYM, ELF, PE/PDB, Portable PDB, - WebAssembly, Breakpad, source bundles) via a bundled `symbolic` WASM module. +- `check`, `bundle-sources`, and `bundle-jvm` are **local-only** — they make no + network requests. They parse object files in-process (Mach-O/dSYM, ELF, + PE/PDB, Portable PDB, WebAssembly, Breakpad, source bundles) via a bundled + `symbolic` WASM module. - `check` exits non-zero if the file is not usable for symbolication (no debug id or no useful features). +- `bundle-sources` reads source files from the paths recorded in the debug info, + so it is normally run on the build machine right after compiling. Referenced + files that are not present locally are skipped; it exits non-zero (writing + nothing) when none are found. The bundle defaults to `.src.zip` and is + uploaded via `sentry debug-files upload`. - Upload a JVM bundle separately via `sentry debug-files upload --type jvm`. - Supported JVM source file extensions: `.java`, `.kt`, `.scala`, `.sc`, `.groovy`, `.gvy`, `.gy`, `.gsh`, `.clj`, `.cljc` diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 800b9024d..64c280905 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -412,6 +412,7 @@ Work with debug information files - `sentry debug-files check ` — Inspect a debug information file - `sentry debug-files bundle-jvm ` — Create a JVM source bundle for source context +- `sentry debug-files bundle-sources ` — Bundle a debug file's source files for source context → Full flags and examples: `references/debug-files.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md b/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md index bd53d5850..ded8b680d 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md @@ -24,6 +24,13 @@ Create a JVM source bundle for source context - `-d, --debug-id - Debug ID (UUID) to stamp on the bundle` - `-e, --exclude ... - Additional directory names to exclude (repeatable)` +### `sentry debug-files bundle-sources ` + +Bundle a debug file's source files for source context + +**Flags:** +- `-o, --output - Output path for the source bundle ZIP (default: .src.zip)` + **Examples:** ```bash @@ -32,6 +39,10 @@ sentry debug-files check ./libexample.so sentry debug-files check MyApp.dSYM/Contents/Resources/DWARF/MyApp sentry debug-files check ./app.pdb --json +# Bundle a debug file's referenced source files (run on the build machine) +sentry debug-files bundle-sources ./libexample.so +sentry debug-files bundle-sources ./app.pdb --output ./app.src.zip + # Bundle JVM sources with a debug ID sentry debug-files bundle-jvm --output ./out --debug-id ./src diff --git a/src/commands/debug-files/bundle-sources.ts b/src/commands/debug-files/bundle-sources.ts new file mode 100644 index 000000000..845679a20 --- /dev/null +++ b/src/commands/debug-files/bundle-sources.ts @@ -0,0 +1,171 @@ +/** + * sentry debug-files bundle-sources + * + * Build a source bundle from the source files referenced by a debug + * information file (Mach-O/dSYM, ELF, PE/PDB, Portable PDB, WASM, Breakpad). + * The bundle is a ZIP archive carrying the object's debug id, which can be + * uploaded to Sentry for source context in stack traces. + * + * Source files are read from the paths recorded in the debug info, so this is + * typically run on the build machine right after compiling. Referenced files + * that are not present locally are skipped. + * + * Local-only — no API calls. Parsing and bundling happen in-process via the + * bundled `symbolic` WASM module (see `src/lib/dif/`). + */ + +import { readFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { createSourceBundle } from "../../lib/dif/index.js"; +import { ValidationError } from "../../lib/errors.js"; +import { + colorTag, + mdKvTable, + renderMarkdown, +} from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { logger } from "../../lib/logger.js"; +import { readDebugFile } from "./read-file.js"; + +const log = logger.withTag("debug-files.bundle-sources"); + +const USAGE_HINT = "sentry debug-files bundle-sources "; + +/** Structured result for the bundle-sources command. */ +type BundleSourcesResult = { + /** Path to the inspected debug information file. */ + path: string; + /** Path the bundle was written to, or `null` if no bundle was produced. */ + outputPath: string | null; + /** Debug id of the bundled object, or `null` if the file has no objects. */ + debugId: string | null; + /** Number of source files included in the bundle. */ + fileCount: number; +}; + +/** Human-readable formatter for the bundle result. */ +function formatBundleResult(data: BundleSourcesResult): string { + if (data.outputPath === null) { + return renderMarkdown( + colorTag( + "warning", + "No source files referenced by this debug file were found on disk; nothing was bundled." + ) + ); + } + const rows: [string, string][] = [ + ["Output", data.outputPath], + ["Debug ID", data.debugId ?? colorTag("muted", "none")], + ["Files bundled", String(data.fileCount)], + ]; + return renderMarkdown(mdKvTable(rows)); +} + +export const bundleSourcesCommand = buildCommand({ + // Local-only: parses + bundles in-process, no API calls. + auth: false, + docs: { + brief: "Bundle a debug file's source files for source context", + fullDescription: + "Build a source bundle from the source files referenced by a debug " + + "information file. The bundle is a ZIP archive stamped with the " + + "object's debug id that can be uploaded to Sentry (debug-files upload) " + + "for source context in stack traces. Supports Mach-O/dSYM, ELF, " + + "PE/PDB, Portable PDB, WebAssembly, and Breakpad.\n\n" + + "Source files are read from the paths recorded in the debug info, so " + + "this is normally run on the build machine right after compiling. " + + "Referenced files that are not present locally are skipped. The format " + + "is auto-detected, and this command makes no network requests.\n\n" + + "Usage:\n" + + " sentry debug-files bundle-sources ./libexample.so\n" + + " sentry debug-files bundle-sources ./app.pdb -o ./app.src.zip\n\n" + + "Exits non-zero if no referenced source files are found on disk.", + }, + output: { + human: formatBundleResult, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Path to the debug information file", + parse: String, + placeholder: "path", + }, + ], + }, + flags: { + output: { + kind: "parsed", + parse: String, + brief: + "Output path for the source bundle ZIP (default: .src.zip)", + optional: true, + }, + }, + aliases: { + o: "output", + }, + }, + async *func(this: SentryContext, flags: { output?: string }, path: string) { + const content = await readDebugFile(path); + + let result: ReturnType; + try { + result = createSourceBundle( + new Uint8Array(content), + basename(path), + (sourcePath) => { + try { + return readFileSync(sourcePath); + } catch (err) { + log.debug( + `Source file not available, skipping: ${sourcePath}`, + err + ); + return null; + } + } + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new ValidationError( + `'${path}' is not a recognized debug information file: ${msg}`, + "path" + ); + } + + if (result.bundle === null || result.fileCount === 0) { + // this.process === the global process in production (see buildContext); + // using it here keeps the exit-code observable in tests. + this.process.exitCode = 1; + yield new CommandOutput({ + path, + outputPath: null, + debugId: result.debugId, + fileCount: 0, + }); + return { + hint: `No source files found on disk for '${path}'. This is normally run on the build machine. Try: ${USAGE_HINT}`, + }; + } + + const outputPath = resolve(flags.output ?? `${path}.src.zip`); + await writeFile(outputPath, result.bundle); + + yield new CommandOutput({ + path, + outputPath, + debugId: result.debugId, + fileCount: result.fileCount, + }); + + return { + hint: `Created ${outputPath} with ${result.fileCount} source file(s). Upload with: sentry debug-files upload`, + }; + }, +}); diff --git a/src/commands/debug-files/check.ts b/src/commands/debug-files/check.ts index fe038089a..c5bb470d7 100644 --- a/src/commands/debug-files/check.ts +++ b/src/commands/debug-files/check.ts @@ -12,7 +12,6 @@ * no useful features), mirroring the legacy `sentry-cli difutil check`. */ -import { readFile } from "node:fs/promises"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { type DifArchiveInfo, parseDebugFile } from "../../lib/dif/index.js"; @@ -23,6 +22,7 @@ import { renderMarkdown, } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; +import { readDebugFile } from "./read-file.js"; const USAGE_HINT = "sentry debug-files check "; @@ -80,30 +80,6 @@ function formatCheckResult(data: DebugFilesCheckResult): string { return out; } -/** - * Read a file from disk with descriptive error handling. - * - * @throws {ValidationError} On ENOENT, EISDIR, or other read failures. - */ -async function readDebugFile(path: string): Promise { - try { - return await readFile(path); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new ValidationError(`File '${path}' does not exist.`, "path"); - } - if (code === "EISDIR") { - throw new ValidationError( - `Path '${path}' is a directory, not a debug information file.`, - "path" - ); - } - const msg = err instanceof Error ? err.message : String(err); - throw new ValidationError(`Cannot read file '${path}': ${msg}`, "path"); - } -} - /** * Nil debug id (hyphenated UUID form). A debug id starting with this means the * object carries no real identifier. PE/PDB ids may append an `-` suffix, diff --git a/src/commands/debug-files/index.ts b/src/commands/debug-files/index.ts index 7e694be38..579cfdf68 100644 --- a/src/commands/debug-files/index.ts +++ b/src/commands/debug-files/index.ts @@ -6,12 +6,14 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { bundleJvmCommand } from "./bundle-jvm.js"; +import { bundleSourcesCommand } from "./bundle-sources.js"; import { checkCommand } from "./check.js"; export const debugFilesRoute = buildRouteMap({ routes: { check: checkCommand, "bundle-jvm": bundleJvmCommand, + "bundle-sources": bundleSourcesCommand, }, docs: { brief: "Work with debug information files", diff --git a/src/commands/debug-files/read-file.ts b/src/commands/debug-files/read-file.ts new file mode 100644 index 000000000..ac54407b6 --- /dev/null +++ b/src/commands/debug-files/read-file.ts @@ -0,0 +1,32 @@ +/** + * Shared file reading for `debug-files` commands. + */ + +import { readFile } from "node:fs/promises"; +import { ValidationError } from "../../lib/errors.js"; + +/** + * Read a debug information file from disk with descriptive error handling. + * + * @param path - Path to the file. + * @returns The file contents. + * @throws {ValidationError} On ENOENT, EISDIR, or other read failures. + */ +export async function readDebugFile(path: string): Promise { + try { + return await readFile(path); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError(`File '${path}' does not exist.`, "path"); + } + if (code === "EISDIR") { + throw new ValidationError( + `Path '${path}' is a directory, not a debug information file.`, + "path" + ); + } + const msg = err instanceof Error ? err.message : String(err); + throw new ValidationError(`Cannot read file '${path}': ${msg}`, "path"); + } +} diff --git a/src/lib/dif/index.ts b/src/lib/dif/index.ts index 5240fc701..f42aacc6b 100644 --- a/src/lib/dif/index.ts +++ b/src/lib/dif/index.ts @@ -15,7 +15,7 @@ import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { Archive, initSync } from "@sentry/symbolic"; +import { Archive, initSync, SourceBundleWriter } from "@sentry/symbolic"; import { logger } from "../logger.js"; const log = logger.withTag("dif"); @@ -156,3 +156,65 @@ export function peekFormat(data: Uint8Array): string { ensureInitialized(); return Archive.peek(data) ?? "unknown"; } + +/** Result of building a source bundle from a debug information file. */ +export type SourceBundleResult = { + /** The source bundle ZIP bytes, or `null` if the bundle would be empty. */ + bundle: Uint8Array | null; + /** Debug id of the object the bundle was built for, or `null` if the file has no objects. */ + debugId: string | null; + /** Number of source files included in the bundle. */ + fileCount: number; +}; + +/** + * Build a source bundle (a ZIP archive) from the source files referenced by a + * debug information file. + * + * The object's debug info is walked for referenced source paths; for each, + * `readSource` is invoked to supply that file's contents. Return `null` from + * `readSource` to skip a path that isn't available locally. The bundle is built + * entirely in memory; nothing is read from disk by this function itself. + * + * The bundle is built for the first object that carries debug info (falling back + * to the first object), which matches the single-object debug files this is used + * for; fat archives with multiple debug-info slices are not split here. + * + * @param data - The full contents of the debug information file. + * @param objectName - Name stamped on the bundle (typically the input file name). + * @param readSource - Supplies source content for a referenced path, or `null` to skip. + * Invoked synchronously, so it must read synchronously (e.g. `readFileSync`). + * @returns The bundle bytes (or `null` if empty), the object's debug id, and the + * number of files included. + * @throws If the buffer cannot be parsed, or if `readSource` throws. + */ +export function createSourceBundle( + data: Uint8Array, + objectName: string, + readSource: (path: string) => Uint8Array | null +): SourceBundleResult { + ensureInitialized(); + const archive = new Archive(data); + const objects = archive.objects(); + const object = objects.find((o) => o.hasDebugInfo) ?? objects[0]; + if (!object) { + return { bundle: null, debugId: null, fileCount: 0 }; + } + + let fileCount = 0; + const writer = new SourceBundleWriter(); + // The filter runs before each file; we include everything the object + // references and let the provider decide availability (returning null skips). + const filter = (_path: string): boolean => true; + const provider = (path: string): Uint8Array | null => { + const content = readSource(path); + if (content !== null) { + fileCount += 1; + } + return content; + }; + + const bundle = + writer.writeObject(object, objectName, filter, provider) ?? null; + return { bundle, debugId: object.debugId, fileCount }; +} diff --git a/test/commands/debug-files/bundle-sources.test.ts b/test/commands/debug-files/bundle-sources.test.ts new file mode 100644 index 000000000..d49556da3 --- /dev/null +++ b/test/commands/debug-files/bundle-sources.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for `sentry debug-files bundle-sources`. + * + * Uses Breakpad symbol files (a deterministic text format) as fixtures. The + * `FILE` record points at a real file inside the test's temp dir so the + * command's on-disk source read actually succeeds — no committed binaries and + * identical behavior on every platform. + */ + +import { access, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { run } from "@stricli/core"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { app } from "../../../src/app.js"; +import type { SentryContext } from "../../../src/context.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("debug-files-bundle-sources-"); + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "df-bundle-sources-test-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +/** + * Build a Breakpad symbol file that references a single source file. + * + * @param sourcePath - Absolute path recorded in the `FILE` record. + */ +function breakpadReferencing(sourcePath: string): string { + return [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "INFO CODE_ID DAA5130F2A41F7FBC8662048F3294F3D439CA7FF", + `FILE 0 ${sourcePath}`, + "FUNC 1000 10 0 main", + "1000 10 42 0", + ].join("\n"); +} + +const KNOWN_DEBUG_ID = "0f13a5da-412a-fbf7-c866-2048f3294f3d"; + +/** Run `debug-files bundle-sources` and capture stdout + exit code. */ +async function runBundleSources( + args: string[] +): Promise<{ output: string; exitCode: number | undefined }> { + let output = ""; + const mockContext: SentryContext = { + process: { ...process, exitCode: undefined } as typeof process, + env: process.env, + cwd: process.cwd(), + homeDir: "/tmp", + configDir: "/tmp", + stdout: { + write(data: string | Uint8Array) { + output += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }, + stderr: { write: () => true }, + stdin: process.stdin, + }; + + await run(app, ["debug-files", "bundle-sources", ...args], mockContext); + return { output, exitCode: mockContext.process.exitCode }; +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +describe("sentry debug-files bundle-sources", () => { + test("bundles a referenced source file that exists on disk", async () => { + const sourcePath = join(tempDir, "example.c"); + await writeFile(sourcePath, "int main(void) { return 0; }\n"); + const debugPath = join(tempDir, "example.sym"); + await writeFile(debugPath, breakpadReferencing(sourcePath)); + + const { output, exitCode } = await runBundleSources([debugPath]); + + expect(exitCode).toBe(0); + expect(output).toContain(KNOWN_DEBUG_ID); + expect(output).toContain("1"); + expect(await exists(`${debugPath}.src.zip`)).toBe(true); + }); + + test("writes to a custom --output path", async () => { + const sourcePath = join(tempDir, "example.c"); + await writeFile(sourcePath, "int main(void) { return 0; }\n"); + const debugPath = join(tempDir, "example.sym"); + await writeFile(debugPath, breakpadReferencing(sourcePath)); + const outPath = join(tempDir, "custom.src.zip"); + + const { exitCode } = await runBundleSources([debugPath, "-o", outPath]); + + expect(exitCode).toBe(0); + expect(await exists(outPath)).toBe(true); + expect(await exists(`${debugPath}.src.zip`)).toBe(false); + }); + + test("exits non-zero and writes nothing when no sources are on disk", async () => { + // FILE record points at a path that does not exist. + const debugPath = join(tempDir, "example.sym"); + await writeFile(debugPath, breakpadReferencing(join(tempDir, "missing.c"))); + + const { output, exitCode } = await runBundleSources([debugPath]); + + expect(exitCode).toBe(1); + expect(output.toLowerCase()).toContain("no source files"); + expect(await exists(`${debugPath}.src.zip`)).toBe(false); + }); + + test("fails with a validation error for a path that does not exist", async () => { + // ValidationError -> exit code 21. + const { exitCode } = await runBundleSources([ + join(tempDir, "does-not-exist.sym"), + ]); + expect(exitCode).toBe(21); + }); + + test("fails with a validation error for a non-debug file", async () => { + const path = join(tempDir, "garbage.bin"); + await writeFile(path, "not an object file"); + + const { exitCode } = await runBundleSources([path]); + expect(exitCode).toBe(21); + }); +}); diff --git a/test/lib/dif/index.test.ts b/test/lib/dif/index.test.ts index 15c7bce55..77418e26c 100644 --- a/test/lib/dif/index.test.ts +++ b/test/lib/dif/index.test.ts @@ -7,7 +7,11 @@ */ import { describe, expect, test } from "vitest"; -import { parseDebugFile, peekFormat } from "../../../src/lib/dif/index.js"; +import { + createSourceBundle, + parseDebugFile, + peekFormat, +} from "../../../src/lib/dif/index.js"; /** A minimal, valid Breakpad symbol file with a known debug id + code id. */ const BREAKPAD_FIXTURE = [ @@ -18,6 +22,15 @@ const BREAKPAD_FIXTURE = [ "PUBLIC 2000 0 some_symbol", ].join("\n"); +/** Breakpad file that references one source file (FILE 0) via a line record. */ +const BREAKPAD_WITH_SOURCE = [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "INFO CODE_ID DAA5130F2A41F7FBC8662048F3294F3D439CA7FF", + "FILE 0 /src/example.c", + "FUNC 1000 10 0 main", + "1000 10 42 0", +].join("\n"); + function toBytes(s: string): Uint8Array { return new TextEncoder().encode(s); } @@ -82,3 +95,59 @@ describe("parseDebugFile", () => { ); }); }); + +describe("createSourceBundle", () => { + test("bundles a referenced source file supplied by the provider", () => { + const requested: string[] = []; + const result = createSourceBundle( + toBytes(BREAKPAD_WITH_SOURCE), + "example", + (path) => { + requested.push(path); + return toBytes(`// ${path}`); + } + ); + + expect(requested).toContain("/src/example.c"); + expect(result.fileCount).toBe(1); + expect(result.debugId).toBe("0f13a5da-412a-fbf7-c866-2048f3294f3d"); + expect(result.bundle).toBeInstanceOf(Uint8Array); + expect((result.bundle as Uint8Array).length).toBeGreaterThan(0); + }); + + test("produces no bundle when the provider supplies nothing", () => { + const result = createSourceBundle( + toBytes(BREAKPAD_WITH_SOURCE), + "example", + () => null + ); + expect(result.fileCount).toBe(0); + expect(result.bundle).toBeNull(); + }); + + test("produces no bundle when the object references no sources", () => { + const result = createSourceBundle( + toBytes(BREAKPAD_FIXTURE), + "example", + () => toBytes("unused") + ); + expect(result.fileCount).toBe(0); + expect(result.bundle).toBeNull(); + }); + + test("surfaces a provider error as a throw (not a silent partial bundle)", () => { + // The error crosses the wasm boundary, so the original message isn't + // preserved; what matters is that it throws rather than skipping silently. + expect(() => + createSourceBundle(toBytes(BREAKPAD_WITH_SOURCE), "example", () => { + throw new Error("read failed"); + }) + ).toThrow(); + }); + + test("throws on unrecognized data", () => { + expect(() => + createSourceBundle(toBytes("not an object file"), "x", () => null) + ).toThrow(); + }); +}); From c62272899d3de30621be625e8fae957d4563d91a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 23 Jun 2026 13:45:39 +0000 Subject: [PATCH 2/2] refactor(debug-files): address review on bundle-sources - Create the --output parent directory before writing (mirrors bundle-jvm), so '-o nested/dir/out.src.zip' works instead of failing with a raw ENOENT. - Warn when the archive has more than one object: only the first debug-info slice is bundled, so fat dSYMs aren't silently shortchanged. - Add objectCount to SourceBundleResult to drive that warning. - Tighten the command test (assert the 'Files bundled' row) and cover the missing-output-directory case. --- .lore.md | 6 ++++++ src/commands/debug-files/bundle-sources.ts | 11 +++++++++-- src/lib/dif/index.ts | 7 +++++-- test/commands/debug-files/bundle-sources.test.ts | 15 ++++++++++++++- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/.lore.md b/.lore.md index 8b3eddac7..60ccf6b17 100644 --- a/.lore.md +++ b/.lore.md @@ -399,6 +399,9 @@ * **Always record detailed session observations with timestamped bullet points**: The user consistently documents work sessions as structured observation logs with emoji status indicators (🟡 in-progress, 🔴 decision/blocker), timestamps in (HH:MM) format, and granular bullet points covering: design decisions made and alternatives rejected, exact file paths and line numbers changed, compile/test results with timing, and explicit rationale for choices. These logs appear to be maintained as a persistent record across sessions. When working, always produce or update this style of log — capturing not just what was done but why alternatives were rejected, exact technical details (commit hashes, line numbers, timing), and outcome verification (test counts, build success). + +* **Always record precise pre-edit state before making code changes**: Before any code modification, the user expects a thorough read and documentation of the exact pre-edit state: function signatures, line numbers, types, logic flow, imports, and any bugs or design decisions. This applies especially when refactoring — the user wants the assistant to capture what exists (e.g., 'Pre-edit \`loadOldBinary\` bug: ...', 'Pre-edit \`OldFileHandle\` type: ...') before proposing or applying changes. The pattern also includes noting which exports/tests reference the code being changed. This ensures the assistant has full context and can reason about regressions, API compatibility, and test impacts before touching anything. + * **Always reference external tools and prior art when exploring build/size optimization approaches**: When investigating build pipeline improvements or binary size reduction, the user consistently references specific external tools, repos, and contacts (e.g., Vercel's build-binary.mjs, binpunch, fossilize, Melkey's work) as starting points for evaluation. They expect the assistant to analyze whether each referenced approach actually applies to their specific setup before recommending it. The user wants a clear breakdown of what's relevant vs. irrelevant given their actual architecture (e.g., 'we already use esbuild full bundling, so node\_modules stripping doesn't apply'), followed by concrete alternative opportunities ranked by impact. @@ -420,6 +423,9 @@ * **Always review drafted messages before sending, then explicitly approve sending**: When the assistant drafts a Slack/Beeper message on the user's behalf, the user expects it to be pre-filled or staged for review — not auto-sent. The user then explicitly approves sending (e.g., selecting an option, confirming intent). The assistant should never auto-send communications without a clear send instruction from the user. Once the user gives explicit approval (e.g., 'send it', selecting a send option), the assistant should proceed to send immediately. + +* **Always run vitest directly instead of via test:unit script to avoid slow doc/SDK generation**: When running tests, the user consistently bypasses the \`pnpm run test:unit\` (or \`pnpm test\`) script because it runs \`generate:docs\` and \`generate:sdk\` as a prelude, making it too slow or causing timeouts. Instead, invoke vitest directly (e.g., \`pnpm exec vitest run \\`) to skip the generation steps. This applies whenever running specific test files or a subset of tests during development. Only use the full test script when a complete suite run with doc/SDK generation is explicitly required. + * **Always sequence npm publication before CLI integration (no vendored blobs)**: Always sequence npm publication before CLI integration (no vendored blobs): Publish upstream packages to npm first through the proper release pipeline before integrating into the CLI. Correct sequence: (1) create and merge full upstream PR including CI, build, and craft/npm targets, (2) wait for npm publish, (3) open/merge CLI PR consuming it as a proper npm dependency. CLI PR #1109 (MERGED): \`@sentry/symbolic@13.3.0\` consumed as devDependency (pinned exactly — no \`^\` or \`~\` for Sentry-scoped packages). 3-path wasm loader: (1) SEA: \`node:sea.getRawAsset(DIF\_WASM\_ASSET\_KEY)\`, (2) npm bundle: sibling \`dist/vendor/symbolic\_bg.wasm\` via \`existsSync(new URL('./vendor/symbolic\_bg.wasm', import.meta.url))\`, (3) dev: \`readFileSync(\_require.resolve(SYMBOLIC\_WASM\_SUBPATH))\`. \`@sentry/symbolic/symbolic\_bg.wasm\` marked \`external\` in esbuild (both \`build.ts\` and \`bundle.ts\`). Vendored crate \`crates/dif-wasm/\` and \`src/lib/dif/vendor/\` fully deleted. diff --git a/src/commands/debug-files/bundle-sources.ts b/src/commands/debug-files/bundle-sources.ts index 845679a20..232567746 100644 --- a/src/commands/debug-files/bundle-sources.ts +++ b/src/commands/debug-files/bundle-sources.ts @@ -15,8 +15,8 @@ */ import { readFileSync } from "node:fs"; -import { writeFile } from "node:fs/promises"; -import { basename, resolve } from "node:path"; +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { createSourceBundle } from "../../lib/dif/index.js"; @@ -154,7 +154,14 @@ export const bundleSourcesCommand = buildCommand({ }; } + if (result.objectCount > 1) { + log.warn( + `'${path}' contains ${result.objectCount} objects; bundled sources for ${result.debugId} only. Other slices are not included.` + ); + } + const outputPath = resolve(flags.output ?? `${path}.src.zip`); + await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, result.bundle); yield new CommandOutput({ diff --git a/src/lib/dif/index.ts b/src/lib/dif/index.ts index f42aacc6b..a00b5707d 100644 --- a/src/lib/dif/index.ts +++ b/src/lib/dif/index.ts @@ -165,6 +165,8 @@ export type SourceBundleResult = { debugId: string | null; /** Number of source files included in the bundle. */ fileCount: number; + /** Total number of objects in the archive (a bundle is built for one of them). */ + objectCount: number; }; /** @@ -196,9 +198,10 @@ export function createSourceBundle( ensureInitialized(); const archive = new Archive(data); const objects = archive.objects(); + const objectCount = objects.length; const object = objects.find((o) => o.hasDebugInfo) ?? objects[0]; if (!object) { - return { bundle: null, debugId: null, fileCount: 0 }; + return { bundle: null, debugId: null, fileCount: 0, objectCount }; } let fileCount = 0; @@ -216,5 +219,5 @@ export function createSourceBundle( const bundle = writer.writeObject(object, objectName, filter, provider) ?? null; - return { bundle, debugId: object.debugId, fileCount }; + return { bundle, debugId: object.debugId, fileCount, objectCount }; } diff --git a/test/commands/debug-files/bundle-sources.test.ts b/test/commands/debug-files/bundle-sources.test.ts index d49556da3..e56b748f2 100644 --- a/test/commands/debug-files/bundle-sources.test.ts +++ b/test/commands/debug-files/bundle-sources.test.ts @@ -91,7 +91,7 @@ describe("sentry debug-files bundle-sources", () => { expect(exitCode).toBe(0); expect(output).toContain(KNOWN_DEBUG_ID); - expect(output).toContain("1"); + expect(output).toContain("Files bundled"); expect(await exists(`${debugPath}.src.zip`)).toBe(true); }); @@ -109,6 +109,19 @@ describe("sentry debug-files bundle-sources", () => { expect(await exists(`${debugPath}.src.zip`)).toBe(false); }); + test("creates the output directory if it does not exist", async () => { + const sourcePath = join(tempDir, "example.c"); + await writeFile(sourcePath, "int main(void) { return 0; }\n"); + const debugPath = join(tempDir, "example.sym"); + await writeFile(debugPath, breakpadReferencing(sourcePath)); + const outPath = join(tempDir, "nested", "dir", "out.src.zip"); + + const { exitCode } = await runBundleSources([debugPath, "-o", outPath]); + + expect(exitCode).toBe(0); + expect(await exists(outPath)).toBe(true); + }); + test("exits non-zero and writes nothing when no sources are on disk", async () => { // FILE record points at a path that does not exist. const debugPath = join(tempDir, "example.sym");