diff --git a/.lore.md b/.lore.md index 00c5b54b2..291f582d6 100644 --- a/.lore.md +++ b/.lore.md @@ -7,15 +7,9 @@ * **@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. - -* **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 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). - -* **binpatch progress UX contract: library emits, consumer renders**: binpatch \`src/events.ts\` exports \`ProgressEvent\`, \`ProgressHandler\`, \`ProgressPhase\`, \`safeProgress\` — the library EMITS these events but NEVER renders them. Consumers handle all UI rendering (progress bars, spinners, etc.). This is the explicit contract: \`safeProgress(handler, evt)\` is the safe emit helper that catches handler exceptions. Any future PR that adds console/log output for progress inside the library violates this contract — push rendering to the consumer. Applies to all binpatch patches; library stays UI-agnostic. - * **binpatch TRDIFF10 wire format + OCI tag scheme constants**: binpatch wire format (TRDIFF10): 8-byte magic \`TRDIFF10\x00\`, then LE int64 \`controlLen\`/\`diffLen\`/\`newSize\` (sign-magnitude), 24-byte control tuples with \`readDiffBy\`/\`readExtraBy\`/\`seekBy\` fields, zstd-compressed control/diff/extra blocks. OCI tag scheme: \`\:nightly\` (mutable pointer), \`\:nightly-\\` (immutable), \`\:patch-\\` (patches). Annotations: \`from-version=\\` (pointer, NOT hash — trust model), \`sha256-\=\\` (final binary hash only), \`org.opencontainers.image.title\`. Artifact type: \`application/vnd.\.patch\`. Security limits: \`MAX\_OUTPUT\_SIZE=2\_147\_483\_648\` (2 GiB), \`MAX\_NIGHTLY\_CHAIN\_DEPTH=30\`, \`MAX\_STABLE\_CHAIN\_DEPTH=10\`, \`SIZE\_THRESHOLD\_RATIO=0.6\`. Patches integrate via SHA-256 of FINAL output only — intermediate hops don't hash (perf). @@ -43,15 +37,9 @@ * **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. - -* **debug-files upload: per-file upload design and assemble body shape**: The \`sentry debug-files upload\` command uses per-file upload (not per-slice). Assemble body shape: \`{ \[overallSha1]: { name, debug\_id?, chunks: string\[] } }\`. Two modes: no-wait (stop once server holds chunks) and \`--wait\` (poll for \`ok\`/\`error\` up to \`ASSEMBLE\_MAX\_WAIT\_MS\`). Filter rules mirror legacy \`filter\_features\`. Auth deferred to \`resolveOrgAndProject()\` (standard cascade). Source bundles via \`createSourceBundle\` when \`--include-sources\`. Deduplication uses \`debugId:sha1(content)\` composite key. Early peek via \`peekFormat()\` in \`prepareDifs\` rejects non-DIF files before full read. Location: \`src/lib/api/debug-files.ts\`, \`src/lib/dif/scan.ts\`, \`src/commands/debug-files/upload.ts\`. - * **delta-upgrade.ts: patch chain resolution and application architecture**: Two channels: stable (GitHub Releases) and nightly (GHCR \`patch-\\` tags). Patch format: TRDIFF10 (zig-bsdiff + zstd). Constants: \`MAX\_STABLE\_CHAIN\_DEPTH=10\`, \`MAX\_NIGHTLY\_CHAIN\_DEPTH=30\`, \`SIZE\_THRESHOLD\_RATIO=0.6\`. Stable: single API call fetches releases with asset metadata, parallel \`Promise.all\` download. Nightly: list tags → filter semver range → fetch manifests → parallel blob download. \`applyPatchesSequentially()\` alternates between two intermediate files (\`${destPath}.patching.a\`/\`.b\`) — never read/write same path (mmap corruption). SHA-256 verified ONCE after all patches applied, not per-intermediate. Cache-first: \`tryLoadCachedChain()\` with key \`patch-chain:{from}-{to}\`. \`canAttemptDelta()\` blocks on dev version, cross-channel, or downgrade. - -* **embedded-ppdb: PE files are dropped, only extracted PPDB is uploaded**: When scanning a managed PE (e.g. .NET assembly) with an embedded Portable PDB, \`difFromCandidateBuffer\` / \`prepareFileDif\` in \`src/lib/dif/scan.ts\` extracts the PPDB and returns it as a separate \`PreparedDif\` — the PE itself is dropped (featureless: no native debug info). Only the PPDB reaches the upload queue. This mirrors legacy \`validate\_dif\` behavior which would reject featureless PEs anyway. The \`--type portablepdb\` filter is required to match; \`--type pe\` alone yields nothing for managed assemblies without native debug info. - * **generate-docs-sections.ts: in-place marker injection into committed files**: \`script/generate-docs-sections.ts\` (555+ lines): injects auto-generated content into committed files between named marker pairs. Marker styles: HTML \`\\` (\`.md\`); MDX \`{/\* GENERATED:START name \*/}\` (\`.mdx\`). \`--check\` flag: dry-run, exits 1 if stale. 13 sections across 5 files: \`contributing.md\` (project-structure, dev-prereq, build-commands), \`DEVELOPMENT.md\` (oauth-scopes, dev-env-vars, dev-prereq, build-toolchain), \`self-hosted.md\` (oauth-scopes, self-hosted-env-vars), \`README.md\` (dev-prereq, library-prereq, dev-scripts), \`getting-started.mdx\` (platform-support). Version extractors (\`extractPnpmVersion\`, \`extractNodeVersion\`) \*\*throw on mismatch\*\* — no silent fallbacks. No Bun references remain. CI \`check-generated\` job runs with \`--check\` flag. @@ -61,9 +49,6 @@ * **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/cli skill system: generate-skill.ts outputs 4 artifacts, SKILL.md is auto-generated**: \`script/generate-skill.ts\` (927 lines) generates skill files from Stricli CLI route tree introspection. Outputs: \`plugins/sentry-cli/skills/sentry-cli/SKILL.md\`, \`plugins/sentry-cli/skills/sentry-cli/references/\*.md\` (26 files), \`docs/public/.well-known/skills/index.json\`, \`src/generated/skill-content.ts\`. The \`skill-content.ts\` embeds all skill files into the binary at build time so \`agent-skills.ts\` installs without network fetching. SKILL.md is auto-generated — never edit manually; regenerate with \`pnpm run generate:docs\`. \`.cursor/skills/sentry-cli/\` contains symlinks to \`plugins/\` location. Claude Code uses \`.claude-plugin/marketplace.json\` at repo root. - * **getsentry/cli two dependabot endpoints: advisories empty, alerts source of truth**: GitHub Dependabot data sources for getsentry/cli: - \`/repos/getsentry/cli/security-advisories\` → empty array (no published advisories for this repo). - \`/repos/getsentry/cli/dependabot/alerts\` → source of truth: 13 open, 15 fixed as of 2026-08-01. Alert taxonomy in this repo: alerts reference lockfiles (stale entries with no source manifest) OR transitive deps (no direct upgrade path). pnpm.overrides (\`package.json\`) is the canonical fix mechanism for transitive vulns — direct dep upgrades cascade via lockfile only. Sibling surfaces: \`pnpm audit\` reports additional CVEs not surfaced by Dependabot (e.g. @ai-sdk/provider-utils@<=3.0.97 LOW CVE-2026-8769) — separate scope, requires package upgrade not override. @@ -73,9 +58,6 @@ * **InkUI teardown order — 6 steps, all try/catch, torndown guard prevents double-unmount**: \`InkUI.tearDown()\` must follow this order: (1) stop tip-rotation interval; (2) detach SIGINT listener + \`store.setRequestCancel(undefined)\`; (3) \`instance.clear()\`; (4) \`instance.unmount()\`; (5) restore alternate screen \`\x1b\[?1049l\`; (6) \`freshStdin.setRawMode(false)\` + \`.pause()\` + \`.destroy()\`. \`torndown: boolean\` guard prevents double-unmount (throws on some platforms). \`cancelRequested\` guard: second Ctrl+C → \`process.exit(130)\`. Every step wrapped in try/catch. - -* **isSaaS() vs isSaaSTrustOrigin: different purposes, same URL source**: In \`src/lib/sentry-urls.ts\`: \`isSaaS()\` (now exported) checks hostname only via \`isSentrySaasUrl(getSentryBaseUrl())\` — used for routing/UX decisions like \`defaultIssueSort()\`. \`isSaaSTrustOrigin\` is separate and requires https + default port — used for credential-trust decisions. JSDoc on \`isSaaS()\` explicitly points to \`isSaaSTrustOrigin\` for credential decisions. Trap: using \`isSaaS()\` for auth/credential gating looks correct but is wrong — it ignores scheme and port. \`getConfiguredSentryUrl()\` reads only env vars; \`cli.ts\` bootstrap (\`preloadProjectContext\`) injects stored SQLite default URL into \`env.SENTRY\_URL\` before commands run, so \`isSaaS()\` sees self-hosted URLs correctly. - * **isSentrySaasUrl vs isSaaSTrustOrigin: two intentional SaaS checks**: \`src/lib/sentry-urls.ts\` exports two SaaS-detection helpers with intentional split: (1) \`isSentrySaasUrl(url)\` — hostname-only check (\`sentry.io\` or \`\*.sentry.io\`), accepts any protocol/port. Used for routing/UX: custom-headers warning, \`getSentryBaseUrl\`/\`isSelfHosted\`, region resolution skip, telemetry \`is\_self\_hosted\` tag. (2) \`isSaaSTrustOrigin(url)\` — stricter: additionally requires \`https:\` and default port. Used for security decisions: token-host trust comparison, sentryclirc URL trust check, URL-arg trust, login refusal. Rule: hostname-only for routing/UX (don't break users behind TLS-terminating proxies with \`http://sentry.io\`); strict for credential scoping. JSDoc on \`isSentrySaasUrl\` points callers to \`isSaaSTrustOrigin\` for security contexts. Keep both implementations in sync re: hostname matching. @@ -88,8 +70,11 @@ * **preprod API has no list endpoint — only 4 paths exist in api-schema.json**: The Sentry preprod/build API has no list operation. Only 4 paths exist in \`api-schema.json\`: \`organizations/{org}/preprodartifacts/{artifact\_id}/install-details/\`, \`organizations/{org}/preprodartifacts/{artifact\_id}/size-analysis/\`, \`projects/{org}/{project}/preprod/size-analysis/status-check-rules/\`, \`projects/{org}/{project}/preprodartifacts/build-distribution/latest/\`. \`@sentry/api\` SDK exports confirm no list operation. A \`build list\` command cannot be implemented without a new server-side endpoint. - -* **preprod-artifacts.ts: auth token only sent to region-origin URLs, artifacts streamed to disk**: Two hard invariants in \`src/lib/api/preprod-artifacts.ts\`: (1) Auth token is NEVER sent to third-party/signed storage URLs — \`isRegionOrigin()\` gates token attachment so it only goes to region-origin requests. (2) Large artifacts are NEVER buffered in memory — always streamed to disk via \`pipeline()\` + \`createWriteStream()\`. Constants: \`SNAPSHOT\_ARCHIVE\_POLL\_MS=2000\`, \`SNAPSHOT\_ARCHIVE\_TIMEOUT\_MS=300\_000\`. \`apiRequestToRegion\` params type: \`Record\\`. + +* **rawApiRequest statusText preservation**: rawApiRequest() preserves Response.statusText and API output treats every status outside 200–299 as an error (\`status < 200 || status >= 300\`), including 304. Empty or whitespace-only textual errors fall back to \`HTTP \ \ — \ /api/0/\\` because Django route misses may have no body. JSON errors preserve \`{status,statusText,body}\`; binary errors use only status/content-type/byte-count summaries; binary successes remain raw Uint8Array. + + +* **renderCompleteDashboardAsSixel canvas**: Chose one complete dashboard sixel canvas over rendering individual sixel widgets because a sixel DCS advances the terminal cursor and cannot safely coexist with the character framebuffer. The compositor preserves adjacent widgets on their original grid row and supplies text/table/error content through the same raster path. If pixel geometry is unavailable, return the complete established character rendering; partial sixel replacement is forbidden because it serializes or misaligns the layout. * **Sentry CLI authenticated fetch architecture with response caching**: Authenticated fetch + response cache: \`createAuthenticatedFetch\`: auth headers, 30s timeout, max 2 retries, 401 refresh, span tracing. \`buildAttemptFactory\` clones \`Request\`; do NOT materialize FormData (strips boundary). Per-endpoint timeout overrides (e.g. \`/autofix/\` 120s). Response cache RFC 7234 at \`~/.sentry/cache/responses/\`, GET 2xx only. TTL tiers: stable=5min, volatile=60s, immutable=24h. \`@sentry/api\` SDK passes Request with no init — undefined init → empty headers stripping Content-Type (HTTP 415); fall back to \`input.headers\` when init undefined. Guard \`Array.isArray(data)\` before \`.map()\` (SDK returns \`{}\` for 204/empty). Tests mocking fetch MUST call \`useTestConfigDir()\` + \`setAuthToken()\` + \`resetCacheState()\` + \`disableResponseCache()\` + \`resetAuthenticatedFetch()\` in beforeEach — GET response cache checked BEFORE fetch, so prior test cache hits produce 0 calls. @@ -103,9 +88,6 @@ * **SQLite dual-driver architecture and WASM runtime gotchas**: Chose dual-driver SQLite approach over single-driver to support Node 18+. Uses node:sqlite (built-in) on Node 22.15+ and node-sqlite3-wasm fallback on Node 18.0-22.14. Required because node:sqlite is unavailable before Node 22.15. node-sqlite3-wasm has incompatible param passing (array vs spread) requiring adapter layer in sqlite.ts. WASM driver always uses spread for bind parameters, never passes undefined (maps to null defensively), and uses manual transaction wrapper. Standalone SEA binary must NOT contain the WASM driver. - -* **src/cli.ts: middleware chain, completion optimization, sensitive argv redaction**: \`src/cli.ts\` exports \`startCli()\`, \`runCli()\`, \`runCompletion()\`. Middleware chain (innermost-first): \`\[seerTrialMiddleware, autoAuthMiddleware]\` — auth is outermost. \`autoAuthMiddleware\` uses \`isatty(0)\` not \`process.stdin.isTTY\` (Bun returns undefined). \`runCompletion()\` sets \`SENTRY\_CLI\_NO\_TELEMETRY=1\` to skip \`@sentry/node-core\` lazy-require (~280ms). \`redactArgv()\` handles \`--flag=value\` and \`--flag \\` forms; \`SENSITIVE\_ARGV\_FLAGS\` includes \`token\` and \`auth-token\`. \`reportUnknownCommand()\` wrapped in try/catch — telemetry must never crash CLI. \`preloadProjectContext()\` calls \`captureEnvTokenHost()\` BEFORE any env mutation. - * **stdin-reopen.ts: forwardFreshTtyToStdin() idempotency and isTTY backfill pattern**: \`src/lib/init/stdin-reopen.ts\` exports \`forwardFreshTtyToStdin(deps?)\` returning a \`Disposable\` (\`TtyForwardingHandle\`) — always non-null so callers use \`using tty = forwardFreshTtyToStdin()\` without null-checking. Idempotency: repeated calls return \`NOOP\_HANDLE\` (secondary callers don't tear down primary's install). isTTY backfill: captures \`previousIsTty\` before touching; if \`undefined\`, uses \`Object.defineProperty\` to set \`isTTY: true, writable: true, configurable: true\` — required because Ink/clack gates \`setRawMode(true)\` on \`input.isTTY\`, so without backfill the fresh fd stays in canonical mode. \`pause\`/\`resume\` replaced with noops to prevent Bun kqueue EINVAL on fd-0 transitions. \`TtyDeps\` allows injection of \`openTty\` and \`isTty\` for test isolation. @@ -158,11 +140,17 @@ ### Gotcha + +* **--fields API envelope filtering**: Trap: applying --fields to an API envelope looks harmless because status metadata is useful, but it prevents expected filtering of the API body and can break array-element selection. Fix: apply field filtering to response.body while preserving {status,statusText,body}; support nested dot notation and array elements without mutating the source, while retaining literal keys containing dots. + * **@stricli/core 1.2.7 patch: -H alias reserved-list removal**: Trap: When Stricli throws on \`-H\` aliases used for \`--header\` or \`--host\`, removing the aliases from command files looks like the simple fix. But the project intentionally uses \`-H\` for curl-style API usage. Fix: the in-repo patch for @stricli/core (targeting 1.2.7) removes \`-H\` from the reserved list. Pin version to \`1.2.7\` (not \`^1.2.8\`) so the patch applies. Never remove \`-H\` alias usages from command files. Added in commit \`78c9b04a5\`. Cursor Bugbot and Seer both flag \`-H\` removal as blocking. - -* **\`--require-all\` false negatives for fat binaries — scan all matched objects**: Trap: \`missingRequestedIds\` computed \`foundIds\` from only the primary object's \`debugId\` per file. Fat Mach-O with multiple slices causes non-primary slice IDs to be reported missing and exits 1 even though they were found. Fix: compute \`foundIds\` from all matched objects (\`.objects\` array), not just \`selectBundledObject()?.debugId\`. Applies to \`src/commands/debug-files/upload.ts\`. + +* **API shared HTTP success predicate**: Trap: telemetry using status >= 400 looks consistent with ordinary HTTP failure handling, but API output treats every status outside 200–299 as an error, so 199 and 3xx responses lose api\_error attributes. Fix: use the exported shared HTTP-success predicate in both output and telemetry, and keep boundary regression tests for 199, 200, 299, and 300. + + +* **api verbose statusText logging**: Trap: logging only the numeric response status looks sufficient because most diagnostics focus on the code, but \`rawApiRequest()\` preserves \`Response.statusText\` and empty-body errors rely on it for useful routing context. Fix: verbose API logging must include both status and statusText, such as \`HTTP 404 Not Found\`, and have regression coverage. * **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. @@ -188,6 +176,9 @@ * **ci.yml set-prev-release-tag has separate bug — chronological vs prior series**: Trap: \`set-prev-release-tag\` (\`.github/workflows/ci.yml:539-568\`, runs on \`release/\*\*\` branches) uses \`gh api "repos/${REPO}/releases?per\_page=5" | jq '\[.\[] | select(.prerelease==false and .draft==false)] | .\[0].tag\_name'\` to pick most recent stable release PERIOD (chronologically). Looks equivalent to the nightly same-series fix but is NOT. If \`0.40.1\` patch lands AFTER \`0.41.0\` is released, \`release/0.41\` builds pick \`0.40.1\` as PREV\_TAG (wrong for \`0.40.0\` users who need \`0.40.0→0.41.0\` delta). Fix requires different approach: derive from branch name (\`release/0.41\` → previous series \`0.40\`). Correctly OUT OF SCOPE for PR #1329 which only fixes nightly path. Release branch path also lacks access to \`nightly-version\` (changes job only outputs it for main). + +* **dashboard --sixel getEnv isolation**: Trap: setting \`process.env.SENTRY\_DASHBOARD\_SIXEL = "1"\` in the dashboard command looks like a simple per-command opt-in. Fix: make the opt-in invocation-scoped through the environment registry/context. SDK invocations replace \`getEnv()\` with an isolated environment, so direct \`process.env\` writes neither enable their render nor stay contained; in CLI mode, never call \`setEnv()\` and avoid leaking sixel into later same-process commands. + * **Dependabot auto-closes reopened PR and opens duplicate — keep the original**: Trap: when a dependabot PR needs intervention (rebase, fix of the bump it introduced), dependabot auto-closes the original and opens a duplicate with the same bump (getsentry/cli #1322 → #1325). The duplicate looks canonical because it has fresh CI runs. Fix: treat the original as canonical — reopen it and close the duplicate as redundant, so review history and the squash-merge land on the intended PR. Part of the duplicate-PR closure discipline in \[\[019fb86a-b8e5-771c-be26-75b90db6d88c]]. Don't switch targets mid-stream. @@ -200,9 +191,6 @@ * **docs-regen workflow force-advances getsentry/cli PR branches with bot commit**: Trap: after force-pushing a rebased PR branch on getsentry/cli (TypeScript), treating your pushed head as the PR head makes final verification run on a stale commit. Fix: the docs-regen GitHub workflow auto-runs on every PR-branch push — it commits 'chore: regenerate docs' via github-actions\[bot] on top of the pushed head and force-advances the branch (PR #1254: pushed 30ad8b075, remote auto-advanced to 605e8318d, touching 33 skill-doc .md files + packages/cli/script/bundle.ts). After any rebase+force-push, re-fetch and fast-forward local to the bot-advanced remote head, then re-run checks on the true head. The bot commit is generated content — do not revert or 'fix' it. Related to the gh PR head\_sha desync trap \[\[019fb86a-b8e5-771c-be26-75b90db6d88c]]. - -* **Error precedence inversion when refactoring stream to openSync/writeSync**: Trap: refactoring \`applyReaderToFile\` from \`createWriteStream\` to \`fs.openSync\`/\`writeSync\`/\`closeSync\` looks like a drop-in replacement, but error precedence silently inverts. OLD code (writer.on('error') pattern): \`finalErr = err ?? writeError\` — close error wins. NEW code (openSync/closeSync pattern): \`if (!writeError) writeError = closeErr\` — write error wins. Fix: when claiming a refactor is a 'drop-in' fix, audit error precedence — which error wins matters for diagnostics. Either preserve precedence via \`closeErr = writeError ?? closeErr\` style, or call out the precedence change in CHANGELOG. Caught in binpatch review at bspatch.ts:677-688. - * **event/view.ts parseSingleArg before parseSlashSeparatedArg**: Trap: \`project/\\` looks like it should flow through generic slash parsing, because it has one slash and resembles \`org/project\`. But \`parseSlashSeparatedArg()\` interprets any single-slash arg as incomplete \`org/project\` and throws \`ContextError\`. Fix: recognize the specific valid \`project/EVENT-ID\` form first with \`parseSingleArg\` + \`HEX\_ID\_RE\`, then fall back to generic slash parsing. Chose specific parser before generic parser because valid special cases get misclassified otherwise. @@ -215,9 +203,6 @@ * **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. - -* **git add -A during rebase sweeps in stray untracked files and .lore.md conflicts**: Trap: \`git add -A\` looks like a safe 'stage everything' shortcut during rebase conflict resolution. But it stages untracked stray files (e.g. \`sentry-lightning-talk.md\`) and auto-stages \`.lore.md\` conflict markers, polluting the commit. Fix: always stage specific file paths explicitly (e.g. \`git add src/commands/debug-files/read-file.ts\`) during rebase resolution. If the rebase is complex, abort with \`git rebase --abort\`, reset to \`origin/main\`, and re-apply edits manually. - * **git commit \ fails after \`git rm\`-stage: use no-arg git commit**: Trap: \`git rm docs/pnpm-lock.yaml && git commit docs/pnpm-lock.yaml\` fails with \`fatal: pathspec 'docs/pnpm-lock.yaml' did not match any files\` because the file is already staged for deletion (not present on disk). Fix: omit the path arg on \`git commit\` — \`git commit\` with no path picks up all staged changes (adds + deletes) and works correctly. The error message looks like a typo but is accurate: the deleted file genuinely doesn't match the path pattern. @@ -239,24 +224,15 @@ * **Local tarball paths in package.json break CI with pnpm install --frozen-lockfile**: Trap: \`file:/tmp/opencode/sentry-symbolic-new.tgz\` in dependencies looks harmless locally — \`pnpm install\` works fine. But CI runs \`pnpm install --frozen-lockfile\`, which exits 254 when the tarball path doesn't exist. The lockfile also changes the specifier from published version to path, causing diffs on every install. Fix: always keep published version specifiers (e.g. \`13.4.0\`) in package.json. Use \`npm pack\` + separate install for local testing, or pnpm overrides. - -* **login.ts blind catch: bare catch around getUserRegions() mislabels network/server failures as invalid token**: Trap: a bare \`catch {}\` around \`getUserRegions()\` in \`src/commands/auth/login.ts\` that always calls \`clearAuth()\` + throws \`AuthError('invalid')\` looks like correct token-validation error handling. But it conflates genuine 401/403 (bad token) with network errors, 5xx server errors, and parse failures — clearing a possibly-valid token and showing a misleading 'Invalid API token' message. Fix (PR #1153): extract \`handleTokenValidationError()\` helper — only clears auth and throws \`AuthError('invalid')\` for \`ApiError\` with status 401 or 403; re-throws original error for all other failures. \`AuthError('invalid')\` is now safe to silence in \`classifySilenced\` because it only fires on genuine auth rejections. - - -* **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); ... })\`. + +* **Mock unmatched 404 body mismatch**: Trap: relying on the mock server's unmatched-route response looks sufficient because it returns a normal JSON 404. Real Django route misses can return an empty 404 body, which is the failure mode issue #1423 covers. Fix: add an explicit empty-body route fixture and E2E test when validating empty-error formatting; do not assume unmatched mock behavior exercises it. * **OpenCode memoizes skill discovery at session start — no hot-reload of skill files or SKILL.md changes**: Trap: modifying \`~/.claude/skills/sentry-cli/SKILL.md\` and seeing count=6 (sentry-cli absent) in the current session looks like a parse/load failure. Fix: OpenCode's \`InstanceState.make\` caches skill discovery once per instance at session start — \`opencode debug skill\` from a fresh invocation shows the true live count (7 including sentry-cli). Stale session snapshots always show the count from when the session started. To verify skill loading, always run \`opencode debug skill\` from a new shell rather than checking \`available\_skills\` in an already-running session. - -* **OpenCode not detected for skill installation — only .claude and .agents roots are supported**: Trap: OpenCode is detected in \`src/lib/detect-agent.ts\` via \`OPENCODE\_CLIENT\` env var and \`PROCESS\_NAME\_AGENTS\` map — looks like it should drive skill installation. But detection is for telemetry only. \`installAgentSkills()\` and \`src/commands/cli/uninstall.ts\` hardcode \`agentRoots = \['.claude', '.agents']\` — OpenCode is never a skill install target. Fix: to add OpenCode skill support, add its root dir to \`agentRoots\` in both \`agent-skills.ts\` and \`uninstall.ts\`, and add a \`detectOpenCode()\` function parallel to \`detectClaudeCode()\`. - * **OutputError must not be preceded by a yield — causes double-render**: Trap: \`OutputError\` looks like a normal error you can throw after yielding a partial result, since other error types allow prior yields. Fix: \`OutputError\` (src/lib/errors.ts:292) is handled in \`src/lib/command.ts:723\` by re-rendering \`err.data\` via \`handleYieldedValue()\` then re-throwing — so any prior \`yield\` of the same data causes double-render. For FAILED/NOT\_RAN terminal states in \`build size\`, throw \`OutputError(result)\` directly without yielding first. - -* **parseWithHash short-circuits before the main validateResourceId guard — must self-validate (CLI-1G1)**: GitHub-style \`org/project#SHORTID\` issue identifiers handled by \`parseWithHash()\` in \`src/lib/arg-parsing.ts\`, inserted in \`parseIssueArg\` AFTER the \`@\`-selector block and BEFORE the \`validateResourceId(input.replace(/\\//g,''))\` guard (line ~1115, which rejects \`#\`). Because it runs before that guard, \`parseWithHash\` MUST validate BOTH the project prefix AND the fragment itself. \`validateResourceId\` permits \`:\`, so \`:\` mixed with \`#\` is rejected explicitly. Semantics: \`org/project#ID\` → delegates to \`parseWithSlash('org/project/ID')\`; \`project#ID\` → \`project-search\` via \`parseProjectIdentifier\`; \`#ID\` → bare identifier via \`parseBareIssueIdentifier\`. \`parseProjectIdentifier\` is shared with \`parseWithColon\`. BEHAVIORAL CHANGE: \`CLI-G#anchor\` went from \`ValidationError\` → \`project-search{projectSlug:'cli-g', suffix:'ANCHOR'}\`. Test at \`arg-parsing.test.ts\` injection-hardening block updated accordingly. - * **pnpm nested script invocation loses TTY — inline tsx to fix**: Trap: pnpm nested script invocation loses TTY — inline tsx to fix — Trap: \`"cli": "pnpm tsx src/bin.ts"\` creates nested pnpm invocations (pnpm → /bin/sh → pnpm → /bin/sh → tsx → node). Each inner pnpm layer pipes stdio, so \`process.stdin.isTTY\` and \`process.stdout.isTTY\` are \`undefined\` in the final Node process. Fix: inline tsx directly — \`"cli": "tsx --import ./script/require-shim.mjs src/bin.ts"\` and same for \`dev\`. @@ -275,6 +251,9 @@ * **preprod build-distribution/latest requires appId + platform query params**: Trap: \`projects/\/\/preprodartifacts/build-distribution/latest/\` looks like it should return the latest build without params. Fix: it requires \`?appId=\\&platform=apple|android\` — omitting either returns 400 \`{"appId":\["This field is required."],"platform":\["This field is required."]}\`. Platform accepts ONLY \`"apple"\` or \`"android"\` — Electron and other platforms are unsupported. Without a real appId that matches an uploaded build, \`latestArtifact\` will be \`null\` (valid 200, no error). + +* **Root Biome command unavailable**: Trap: running \`pnpm biome\` or an equivalent root-level Biome command looks natural in this workspace, but the root does not expose the binary and fails with \`ERR\_PNPM\_RECURSIVE\_EXEC\_FIRST\_FAIL\`. Fix: run Biome from \`packages/cli\` or invoke the package script through \`pnpm --filter sentry run ...\`, because the CLI package owns the tool dependency and scripts. + * **ruzstd partial decompression: must validate output size explicitly**: Trap: \`ruzstd::StreamingDecoder\` (unlike \`zstd::bulk::decompress\`) silently returns a partial result when passed a too-small \`size\` — it does NOT error. Fix: read \`size + 1\` bytes into the output buffer, then assert \`decompressed.len() == size\`; return \`None\` on mismatch. This matches \`zstd::bulk::decompress\` error-on-mismatch semantics. Confirmed via test: exact(560)→Some(560)✓, toosmall(550)→None✓, toolarge(570)→None✓. @@ -290,14 +269,11 @@ * **Silent nonexistent path in scan — throw ValidationError instead of skip**: Trap: \`scanPaths\` silently skipped nonexistent paths (ENOENT from \`stat\` → \`log.debug\` + continue). Users got empty results with no indication the path was missing. Fix: for explicitly provided paths (not directory children), throw \`ValidationError\` with the path name. Directory children that don't exist are still silently skipped. - -* **skill-eval E2E tests fail on Anthropic API network errors — not a code regression**: Trap: \`test/e2e/skill-eval.test.ts\` failures (\`claude-sonnet-4-6 meets threshold\`, \`claude-opus-4-6 meets threshold\`) look like regressions introduced by the current PR. Root cause: these tests call \`api.anthropic.com\` directly — \`\[planner] API error: Invalid response body ... Premature close\` is an external Anthropic API outage, not a code bug. All 126 non-LLM E2E tests pass. Fix: confirm by checking logs for \`Premature close\` pattern; if present, stop re-running (wastes CI resources) and post a PR comment documenting the outage. Do not merge while CI is red — wait for API recovery. - - -* **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;\` + +* **SIXEL measured canvas dimensions**: Trap: passing a measured dashboard canvas to a generic image encoder looks sufficient because the encoder accepts \`maxWidth\`. Fix: preserve the measured width and height with \`allowWide\` or fall back to complete ASCII. Generic image safety caps silently downscale a valid dashboard, breaking its cell-derived grid geometry and cursor layout; narrow terminals must use actual positive columns for sixel geometry or reject sixel, never the ASCII minimum-width clamp. -* **streamDecompressToFile: openSync/writeSync/closeSync, never drain — fd-release race vs spawn**: streamDecompressToFile: never emit 'drain' on ENOSPC — race drain against error to avoid hang +* **streamDecompressToFile fd lifecycle**: streamDecompressToFile: never emit 'drain' on ENOSPC — race drain against error to avoid hang * **strip fails on Node SEA binaries — must strip BEFORE fossilize injection; UPX destroys ELF notes**: 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. @@ -308,12 +284,18 @@ * **Symlink cycle hang in recursive file collection — use lstat + visited-realpath set**: Trap: \`collectFiles\` uses \`stat\` (follows symlinks) with no cycle detection. Directory symlinks pointing to ancestors cause unbounded recursion — never returns. macOS \`.framework\`/dSYM trees routinely contain cyclic symlinks. Fix: use \`lstat\`, skip symlinked directories, and track visited realpaths in a \`Set\` to break cycles. File symlinks are safe to follow. + +* **test:unit ChildProcess.kill failure**: Trap: \`pnpm run test:unit\` looks like the canonical full-suite command, but its wrapper can fail with \`Unknown: ChildProcess.kill\` before reporting test results; this is runner/process cleanup failure, not a test assertion failure. Fix: invoke the equivalent direct Vitest command to obtain a trustworthy result, while remembering that the wrapper also runs docs and SDK generation pre-steps. + * **Upload assembly \`not\_found\` after deadline is a real failure — must set exit code 1**: Trap: upload assembly only treated \`"error"\` state as failure; \`"not\_found"\` was treated as incomplete (exit 0 with debug log). But after deadline, \`not\_found\` means chunks were never delivered — a genuine failure. Fix: treat \`not\_found\` as failure with \`log.warn\` + exit code 1. Also upgrade deadline-break log from \`debug\` to \`warn\`. Discovered during self-review of \`debug-files upload\` PR. * **Vitest fd-lingering regression test: race condition defeats proc/self/fd check**: Trap: a regression test that writes a file via \`applyReaderToFile\` and then checks \`/proc/self/fd\` for lingering fds looks like it should catch a fd-leak bug. It does NOT — vitest's extra awaits in \`applyPatchChainInMemory\` (loadOldBinary → copyFileSync → open → stat → transformPatch → Promise.all(cancel both readers) → writer.end + test body's \`await import("node:fs")\`) drain pending closes before \`readdirSync("/proc/self/fd")\` runs. Confirmed in binpatch: NEW code test passes 100%, OLD \`createWriteStream\` code test passes 0/50 in vitest, but standalone repro catches 11/200 (5.5%). Fix: deterministic structural assertion via helper \`applyReaderToFileOpenHandleCount(destPath)\` that re-opens path and asserts returned fd number > original openSync result — Linux never recycles lowest unused fd while higher-numbered one is open, so a higher returned fd proves closeSync ran. + +* **WASM SQLite bind spread**: Trap: passing a bind-parameter array directly to the WASM SQLite driver looks equivalent to native SQLite APIs, but this driver expects positional parameters as spread arguments. Fix: always call the bind/execute API with spread syntax, because otherwise parameters are interpreted as one value or fail to bind correctly. + * **wasm-pack test never tests the published package — builds its own glue instead**: 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. @@ -343,21 +325,18 @@ * **AGENTS.md auto-recovery wrong entity types**: \`AGENTS.md\` (present by 2026-04-23) contains the repo’s explicit auto-recovery guidance: when user intent is unambiguous, detect the actual identifier type (\`looksLikeIssueShortId\`, \`SPAN\_ID\_RE\`, \`HEX\_ID\_RE\`, non-hex slug checks), resolve to the correct entity, \`log.warn()\`, and return a hint. Chose auto-recovery over strict rejection because wrong-type IDs are common user mistakes; strict errors look cleaner but force needless retries when the intended entity is obvious. - -* **atomicWriteFile in agent-skills.ts: same-dir temp + rename guarantees no partial reads**: \`atomicWriteFile(destPath, content)\` at \`src/lib/agent-skills.ts:72\`: writes to \`.\.\.\.tmp\` in the same directory as \`destPath\`, then calls \`rename()\` into place. Same-directory placement guarantees same filesystem → POSIX atomic rename. Concurrent readers never observe a truncated or partially-written file. Temp file is cleaned up on error. Used by \`writeSkillFiles()\` (replacing in-place \`writeFile\`). Skills are written on every version — write-if-changed optimization was explicitly rejected as unnecessary. + +* **API\_MAX\_PER\_PAGE list pagination**: List commands with \`--limit > API\_MAX\_PER\_PAGE\` (100) must fetch pages until the requested count is reached or pagination ends; set \`perPage = Math.min(flags.limit, API\_MAX\_PER\_PAGE)\`. Use \`buildPaginationContextKey\`, \`resolveCursor\`, \`advancePaginationState\`, \`hasPreviousPage\`, and \`!!nextCursor\`. Never send a larger \`per\_page\`: the API silently caps it, which looks successful but returns fewer items than requested. + + +* **buildCommand Stricli output contract**: New CLI commands use \`buildCommand()\` with \`docs.brief\`, \`docs.fullDescription\`, \`output.human\`, optional \`jsonTransform\`/\`jsonExclude\`, parsed flags, and an async-generator \`func(this: SentryContext, flags)\`. Yield \`new CommandOutput(data)\` and return an optional hint. Chose this over writing stdout directly because the wrapper owns human/JSON rendering, field filtering, clear-screen behavior, and final hints. Route aliases are automatic: list→ls, view→show, delete→remove/rm, create→new. * **CI Node version pinning: centralized env block per workflow file, ternary for matrix jobs**: Node CVE-2026-48931 fix: \`NODE\_VERSION\_22="22.23.1"\`, \`NODE\_VERSION\_24="24.18.0"\` (22.23.0 had the vulnerability; fix landed in 22.23.1 via nodejs/node#64004). Pattern: add top-level \`env:\` block to each workflow file (ci.yml, release.yml, sentry-release.yml, docs-preview.yml) with both constants + rationale comment. Reference via \`${{ env.NODE\_VERSION\_22 }}\`. Matrix jobs (build-npm) use ternary: \`${{ matrix.node == '24' && env.NODE\_VERSION\_24 || env.NODE\_VERSION\_22 }}\` — matrix labels stay as bare majors (\`\["22","24"]\`) for job naming. Gotcha: \`eval-skill-fork.yml\` has no \`setup-node\` step at all — must add one explicitly \[\[019f03bb-f9cf-7208-a183-d4f0074480f9]]. - -* **createSourceBundle: object selection, sync provider contract, writer lifecycle**: \`createSourceBundle(data, objectName, readSource)\` in \`src/lib/dif/index.ts\`: selects \`objects.find(o => o.hasDebugInfo) ?? objects\[0]\`; returns \`{bundle:null, debugId:null, fileCount:0}\` if no objects. \`SourceBundleWriter.writeObject\` is synchronous — provider/filter callbacks must be sync (\`readFileSync\` in bundle-sources.ts). Writer is single-use: \`writeObject\` calls \`\_\_destroy\_into\_raw()\` (zeroes ptr, unregisters FinalizationRegistry). Provider returning \`null\` signals skip (WASM glue checks \`arg0 == null\`). \`bundle === null || fileCount === 0\` correctly catches manifest-only ZIPs with zero source files. - * **debug-files upload: DIF assemble wire format and chunk-upload pipeline**: Native DIF assemble body: \`{ \[overallSha1]: { name, debug\_id?, chunks: string\[] } }\` — identical shape to \`proguard.ts\`/\`dart-symbols.ts\`. \`debug\_id\` is advisory (server re-parses). Per-file upload: each file chunked as raw bytes via \`hashBuffer\`; primary object selected via \`selectBundledObject\` (first with debug info, fallback to first). Assemble endpoint: \`projects/${org}/${project}/files/difs/assemble/\`. Constants: \`DEFAULT\_MAX\_DIF\_SIZE=2GB\`, \`DEFAULT\_MAX\_WAIT=300s\`. \`--wait\` flag controls whether to poll until assembly completes. Deferred: ZIP scanning, BCSymbolMap/dsymutil, Xcode derived-data, il2cpp mapping (require native tools not available in WASM). - -* **Dedupe resolved entity IDs in batch operations before API call**: Batch issue merge (\`src/commands/issue/merge.ts\`): (1) Dedupe by resolved numeric ID after \`Promise.all(args.map(resolveIssue))\` — users may pass same entity as \`CLI-K9\`, \`my-org/CLI-K9\`, or \`123\`. Throw \`ValidationError\` if \`new Set(ids).size < 2\`. (2) Reject \`undefined\` orgs in cross-org check — bare numeric IDs without DSN/config resolve with \`org: undefined\`. (3) Pass \`--into\` through \`resolveIssue()\`; compare by numeric \`id\`, not \`shortId\`. (4) Sentry bulk merge API picks canonical parent by event count — \`--into\` is preference only; warn when API's \`parent\` differs. - * **Dependabot alert fix: orphan-lockfile delete + pnpm.overrides + Group C**: Three-category split for fixing GitHub Dependabot alerts in getsentry/cli (TypeScript): \*\*Group A (stale orphan lockfile):\*\* delete \`docs/pnpm-lock.yaml\`. Orphaned by PR #1254 (docs moved \`docs/\` → \`apps/cli-docs/\`); \`packages/cli/script/paths.ts\` defines \`DOCS\_ROOT = "../../apps/cli-docs"\`. All CI workflows reference \`apps/cli-docs/\`. Deletion eliminates from future scans; editing is wrong. \*\*Group B (active transitive vulns):\*\* add \`pnpm.overrides\` to root \`package.json\`. Shape: \`"\@\": "\"\`. Example: \`"shell-quote@<1.9.0": "1.9.0"\` (NOT \`"<1.8.4": "1.8.4"\`). Run \`pnpm why\` to verify cascade. \*\*Group C (ecosystem/extra CVEs):\*\* CVEs surfaced by \`pnpm audit\` but NOT Dependabot (e.g. @ai-sdk/provider-utils@<=3.0.97 LOW CVE-2026-8769). Requires package upgrade, not override — separate scope. Branch: \`chore/fix-dependabot-alerts-YYYY-MM-DD\`. Label: \`dependencies\`. Commit: \`chore: fix N dependabot alerts via pnpm overrides\`. Precedent: PR #1130, PR #1322. @@ -376,14 +355,11 @@ * **getsentry/symbolic: wasm smoke test pattern — smoke-test.mjs + build-npm.sh + ci.yml wasm-smoke job**: symbolic-wasm smoke test pattern: Two-file approach in \`symbolic-wasm/npm/\`: \`smoke-test.mjs\` (orchestrator: packs tgz, installs to temp dir, resolves wasm via exports map, spawns \`node --test\` on \`package-smoke.test.mjs\`) + \`package-smoke.test.mjs\` (node:test assertions against installed package via \`initSync\`). Test files excluded from \`files\[]\` in \`package.json\` — nothing extra ships to consumers. Wired into \`build-npm.sh\` replacing bare \`npm pack\`. CI: \`wasm-smoke\` job in \`ci.yml\`. \`cd symbolic-wasm/npm && npm test\` runs the suite. Pack+install approach catches exports-map/resolution breakage, not just runtime errors. CRITICAL: \`wasm-pack test --node\` does NOT exercise the shipped artifact — it compiles tests with its own generated glue, never loads \`--target web\` \`symbolic.js\` + \`symbolic\_bg.wasm\` via \`initSync\`. Confirmed by Burak Yigit Kaya: 'wasm-pack test sails past it because it builds its own glue and never loads what we ship'. PR #993 adds smoke tests. - -* **Grouped widget --limit auto-default via applyGroupLimitAutoDefault helper**: Dashboard widget flag normalization: (1) Dataset aliases (errors→error-events) normalize ONCE at top of \`func()\` via \`normalizeDataset()\` in \`src/commands/dashboard/resolve.ts\`. In \`edit.ts\`, pass \`normalizedFlags\` to \`buildReplacement\` — \`validateAggregateNames\` reads \`flags.dataset\` and rejects valid aggregates like \`failure\_rate\` if it sees raw alias. (2) Grouped widgets need \`limit\` (API rejects). \`applyGroupLimitAutoDefault\` defaults to \`DEFAULT\_GROUP\_BY\_LIMIT=5\` only when user passed \`--group-by\` without \`--limit\`; skip for auto-defaulted columns like \`\["issue"]\`. (3) Tests asserting \`--limit\` >10 survives into PUT body must use \`display: "line"\` — \`prepareWidgetQueries\` clamps bar/table to max=10. - * **idle.ts eviction: upstream uses per-function cleanup in idle.ts, not centralized evictSession in pipeline.ts**: Upstream (main branch) puts session eviction logic directly in \`idle.ts\` rather than a centralized \`evictSession()\` in \`pipeline.ts\`. \`idle.ts\` imports cleanup functions individually: \`evictSession as evictGradientSession\` from \`@loreai/core\`; also \`deleteSessionAuth\`, \`clearAuthStale\` from \`./auth\`; \`deleteSessionCosts\` from \`./cost-tracker\`; \`deleteBillingPrefix\` from \`./cch\`; \`clearWarmupAuthDisabled\` from \`./cache-warmer\`. The \`startIdleScheduler\` signature uses \`onEvict?: (sessionID: string) => void\` (upstream) vs \`onEvictSession?: (sessionID: string) => boolean\` (branch). Upstream inline \`onEvict\` in \`pipeline.ts\` cleans 5 Maps: \`headerSessionIndex\`, \`ltmSessionCache\`, \`ltmPinnedText\`, \`stableLtmCache\`, \`cwdWarned\`. When merging, adopt upstream's per-function approach and add any missing cleanup calls. - -* **Monorepo split: packages/cli scripts must preserve root script semantics + paths**: Post-monorepo-split \`packages/cli/package.json\` scripts must mirror root scripts verbatim — including: (1) \`generate:docs\` chains \`generate:banner\` + \`generate:parser\` BEFORE other generators (parser outputs under \`src/generated/\` are gitignored); (2) \`typecheck\`, \`build\`, \`bundle\` MUST run \`generate:docs\` + \`generate:sdk\` as prerequisites (\`src/sdk.generated.ts\` is gitignored and imported by \`src/index.ts\` — bare \`tsc --noEmit\` fails on clean checkout); (3) \`test:e2e\` and \`test:changed\` use direct \`vitest\` invocation; (4) \`check:\*\` scripts point at the real script filenames (\`generate-api-schema.ts\`, \`check-no-deps.ts\`, \`check-error-patterns.ts\`, \`check-stale-references.ts\`, \`generate-banner-sixel.ts\`, \`generate-docs-sections.ts --check\`). Bugbot B1 + B2 caught PR #1254 dropping these chain prerequisites in the rebased base. Fix: take main's \`scripts\` block verbatim into \`packages/cli/package.json\` rather than re-deriving. ADDITIONAL gotcha: ci.yml \`code\` paths-filter must include root workspace config files (\`package.json\`, \`.npmrc\`, \`pnpm-workspace.yaml\`) — \`pnpm.patchedDependencies\`/\`pnpm.overrides\`/\`node-linker\` moved there post-split. + +* **Large artifacts pipeline createWriteStream**: Large artifacts are always written with \`pipeline()\` into \`createWriteStream()\`, rather than buffering the complete payload in memory. Buffering looks simpler and may work for small fixtures, but it creates avoidable memory peaks and weakens backpressure for shipped CLI workloads. * **Node version pinning convention: workflow-level env vars NODE\_VERSION\_22 / NODE\_VERSION\_24**: As of PR #1145, all GitHub Actions workflows in sentry-cli (TypeScript) centralize Node version pins as workflow-level \`env\` vars: \`NODE\_VERSION\_22: "22.23.1"\` and \`NODE\_VERSION\_24: "24.18.0"\`. All \`actions/setup-node\` steps reference \`${{ env.NODE\_VERSION\_22 }}\` or \`${{ env.NODE\_VERSION\_24 }}\` — no bare \`"22"\`/\`"24"\` strings. Matrix jobs use ternary: \`${{ matrix.node == '24' && env.NODE\_VERSION\_24 || env.NODE\_VERSION\_22 }}\`. Motivation: Node 24.17.0/22.23.0 shipped \`ERR\_STREAM\_PREMATURE\_CLOSE\` regression (CVE-2026-48931 http.Agent fix); fixed in 24.18.0/22.23.1 (nodejs/node#64004). When bumping Node, update the \`env\` block in each workflow file. @@ -397,18 +373,12 @@ * **scan.ts: per-object extraction errors always swallowed — never abort surrounding upload**: In \`src/lib/dif/scan.ts\` and \`src/lib/dif/index.ts\`, extraction errors for embedded PPDBs (\`extractEmbeddedPpdb\`), IL2CPP mappings (\`createIl2cppLineMapping\`), and source bundles are caught per-object, logged at debug level, and swallowed — they never abort the surrounding upload or scan. This mirrors legacy Rust sentry-cli behavior. Similarly, \`PeekResult.format\` is never \`'unknown'\` — unrecognized formats return \`null\` from \`peekHeader\`. Nested ZIP archives are never recursed regardless of \`scanZips\` setting. - -* **selectBundledObject: shared generic helper for first-debug-info-else-first selection**: \`selectBundledObject\(items: T\[], hasDI: (t: T) => boolean): T | undefined\` in \`src/lib/dif/index.ts\` is the single source of truth for 'first object with debug info, fallback to first object' selection. Used by both \`createSourceBundle\` (WASM \`Object\[]\`) and \`print-sources\` (multi-object warning). Chosen over duplicating the heuristic in each consumer — divergence between bundler and inspector is structurally impossible. Generic predicate parameter lets it work with both WASM \`ObjectFile\` and \`DifObjectSources\` arrays. - * **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. * **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. - * **symbolic-il2cpp integration tests: use symbolic-testutils dev-dependency with Object::parse pattern**: Integration tests for \`symbolic-il2cpp\` live in \`symbolic-il2cpp/tests/\` (separate from unit tests in \`src/\`). Add \`symbolic-testutils = { path = "../symbolic-testutils" }\` as dev-dependency (path-only, safe for publishing — matches \`symbolic-debuginfo\` pattern). Use \`ByteView::open(fixture("..."))\` → \`Object::parse(\&view)?\` to get a real \`ObjectLike\`. Fixture files live in \`symbolic-testutils/fixtures/\`. Native unit test with mock \`ObjectLike\` rejected as too heavyweight (many methods to implement). PR #1005 added \`from\_object\_with\_provider\_empty\_without\_sources\` and \`from\_object\_with\_provider\_parses\_source\_info\` tests. @@ -426,18 +396,27 @@ ### Preference + +* **\_\_complete failures exit zero**: The \`\_\_complete\` completion path must never crash or emit an error when completion lookup fails; silently return no results and set exit code 0. Completion is queried by shells and an error would break interactive command discovery, so it intentionally has a softer contract than ordinary command execution. + * **Always commit .lore.md changes with explicit file paths (never git stash, never git add -A)**: When .lore.md is modified during a session, the user requires: (1) stage it explicitly via \`git add .lore.md\` (never \`git add -A\` which pulls in stray files and auto-generated conflict markers), (2) commit it as part of the PR branch with a chore message (e.g. \`chore: update .lore.md (background knowledge compaction)\`) rather than stashing — lore note \[019fb821] explicitly states 'NEVER git stash .lore.md changes'. Apply this whenever rebase/merge/resolution would otherwise lose or hide lore context, and when resolving rebase conflicts use \`git add \\` for each conflict individually. * **Always emit explicit user-stated intent as red-highlighted facts and demand binary isolation when classifying fetch vs. content failures**: Across sessions, the user (1) states permanent design assertions verbatim with 🔴 HIGH priority (e.g., "fetch failures are always transient, never poison", "never target the same path", "intermediates NEVER hit disk", "always clean up intermediates on failure") and expects the assistant to treat these as immutable contracts, not refutable hypotheses; (2) insists on narrow, binary classifications — network/transient vs. malformed\_chain, cache hit vs. miss, in-memory vs. disk — and rejects fuzzy or hybrid categorizations; (3) drives investigation toward isolation of the specific failure surface (e.g., isolating the bug to delta-patch vs. spawn/replace by spawning the .download directly), not toward broad refactors. AI should: (a) preserve verbatim user directives as non-negotiable, (b) use the exact enum/string values the user defines for classification, (c) narrow the search space to one layer at a time before proposing fixes. + +* **Always enforce explicit CLI output and rendering contracts**: Treat the user’s stated behavioral requirements as strict invariants, especially for CLI output and dashboard rendering. Preserve mode-specific guarantees: never emit unsafe binary bytes in error output, never contaminate machine-readable JSON, never let prompts block scripts, and provide graceful fallback rather than crashing. For terminal graphics, do not accept simplified fallback layouts that alter intended ordering, leave holes, or downgrade eligible widgets without a real capability limitation. Validate implementation details against stated examples and edge cases, including formatting rules, plain-mode visual semantics, categorical chart support, and layout preservation. + * **Always fetchable from the base repo with github**: User stated always fetchable from the base repo with github. * **Always follow a staged release workflow**: Break repository and release tasks into explicit, prioritized steps: inspect the local repository and configuration first, investigate versioning or target-state issues, implement the smallest required change, add or update tests and documentation, run verification, and review the resulting patch. Only modify downstream CLI or publishing integrations when the investigation shows it is necessary. Track prerequisites and workflow state before rerunning release or publish processes. + +* **Always follow repository-specific engineering directives**: Before modifying or validating code, consult and apply the repository’s documented conventions and current package scripts. The user consistently expects work to respect explicit local rules: run commands from the correct workspace/package, inspect package.json for scripts, keep bundled packages in devDependencies, use prescribed command/output/error patterns, and follow test/documentation requirements. Treat AGENTS.md and .lore.md guidance as authoritative, preserve safety constraints, and use precise absolute language when documenting behavior. + * **Always follow the regular PR workflow with minimal, clean diffs for dependency updates**: When the user asks to fix an issue or bump a dependency (e.g., Starlight, Astro), they expect the standard PR flow: create a properly named branch, commit (including .lore.md), open a PR, review, and merge. Before pushing, verify the change is minimal — inspect package.json and lockfile diffs, confirm peer dependency ranges resolve, and ensure no unrelated churn. Double-check the branch tracks the correct remote and push to the right PR branch. Close any redundant/duplicate PRs (e.g., dependabot replacements). Ensure all CI checks are green; rerun flaky jobs when needed. Only merge after everything is clean. @@ -456,8 +435,14 @@ * **Always migrate Bun-specific APIs and tooling to Node.js equivalents**: Always migrate Bun-specific APIs and tooling to Node.js equivalents: 🔴 Directive (repeated 25+ sessions): ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. NEVER proactively create documentation files (\*.md) or README files — only create documentation files if explicitly requested by the user. + +* **Always preserve complete fallback rendering when geometry is unavailable**: For opt-in sixel dashboard rendering, require sufficient terminal geometry before switching output modes. If pixel width, cell width, or cell height is unavailable or invalid, return the complete established character/ASCII dashboard rendering rather than emitting a partial sixel layout or replacing only some framebuffer content. Treat sixel as an all-or-nothing enhancement: maintain the prior rendering path exactly as the safe fallback. + + +* **Always preserve the original dashboard grid in sixel rendering**: When requesting or reviewing dashboard sixel support, require capable terminals to render the complete dashboard in sixel—not a mixed ASCII/sixel output—and preserve authored widget layout. Adjacent widgets in the same original row must remain side by side rather than being serialized into full-width image blocks; full ASCII fallback is acceptable only when complete sixel rendering is not possible. Include categorical bars and other widget types in the graphics path rather than excluding them merely because an initial renderer lacks support. + -* **Always request read-only adversarial code reviews with structured findings, not code changes**: Always conduct read-only, adversarial code reviews: do not modify files or make commits. Review only the specified files and concerns, run the requested verification commands and relevant tests, and provide categorized findings such as BLOCKING / SHOULD-FIX / NIT with precise file:line references and concrete fix suggestions. Verify claims empirically against source, configuration, upstream artifacts, and reproducible behavior; confirm the working tree remains clean. +* **Always request read-only adversarial code reviews with structured findings, not code changes**: For requested read-only adversarial PR reviews, do not modify files, branches, or commits. Inspect the issue, PR metadata and diff, current base state, relevant implementation/tests, and active review/CI context; run only safe requested verification. Report only empirically verified findings with severity, exact file:line references, evidence, and concrete fixes; distinguish stale findings from real defects, assess stated requirements, preserve a clean worktree, and end with an explicit MERGE or NEEDS-WORK verdict. * **Always require progress UI to be strictly cosmetic and never abort underlying work**: Progress and cosmetic UI code must never throw or abort the underlying operation. Guard every render path, including onProgress callbacks and done(), with try/catch that swallows display errors. Keep progress data accurate and non-misleading; for multi-hop patch chains, use percentage-only formatting when summed byte totals are inflated. Regression tests should verify both that unguarded rendering would fail and that the intended format is actually passed at the call site. @@ -475,10 +460,13 @@ * **Always return to automatic detection after tests**: Always return to automatic detection after tests so we don't leak forced state. Ensures test isolation and prevents state leakage between test cases. -* **Always run full/targeted tests and lint before committing**: Always run targeted tests after a fix, then the full test suite, typecheck, and lint on touched files before committing. Review the complete git diff, distinguish expected warnings from real failures, and amend the commit if verification requires additional fixes. +* **Always run full/targeted tests and lint before committing**: Before committing, pushing, or finalizing PR changes, run targeted tests after a fix, then the relevant full suite, typecheck, and lint. Investigate incomplete or failed runs, distinguish expected non-failing warnings from regressions, review the complete git diff and working tree, and amend the commit if verification requires fixes. Confirm the exact PR-head or remote branch state afterward, especially before force-pushing. -* **Always separate intermediate writes from source reads when applying patch chains**: When applying multi-step patch chains, never read from and write to the same path. Multi-hop chains alternate between two distinct intermediate files (e.g., \`${destPath}.patching.a\` and \`${destPath}.patching.b\`) so the reader is never aliased to the writer — writing to the source would truncate the mmap'd read and corrupt the output. When in-memory chains are feasible, load the base once, keep intermediates in memory, and only write the final binary to disk (then SHA-256 verify). Always clean up intermediate files in a \`finally\` block, even on failure. +* **Always separate intermediate writes from source reads when applying patch chains**: When applying delta or multi-step patch chains, never read from and write to the same path: writing can truncate the source and corrupt output. Keep intermediates isolated in memory or distinct paths, write only the final binary to its destination, verify its SHA-256, and clean up every temporary artifact in a finally block, including on failure. + + +* **Always specify and validate edge-case rendering invariants**: When implementing or reviewing terminal dashboard/chart features, explicitly state non-negotiable behavioral invariants and back them with focused tests. Cover fallback and opt-in paths, preserve existing non-target rendering, and verify layout/cursor safety rather than only happy-path output. Examples include restoring terminal state after probes, assigning special series categories stable visual treatment, preventing sixel extraction from leaving grid holes, accounting for widget height during packing, and ensuring dense-chart downsampling retains late data. Tests should mock terminal capabilities/environment deterministically and assert concrete output boundaries or pixel-level properties. * **Always update .lore.md before committing; always take HEAD on conflicts**: Always verify and update .lore.md before committing; on rebase/cherry-pick conflicts, always take HEAD (main's version) for \`.lore.md\` since it is auto-regenerated. Resolved by stashing with key prefix \`lore-md-pre-rebase-\` before rebase, then restoring and re-committing after. @@ -492,11 +480,8 @@ * **Always verify and address stale review findings against outdated base commits**: When automated reviewers (Bugbot, Seer) flag issues, the user/agent verifies whether findings are against stale base commits or still apply. Across PR #1254 cycles, Bugbot repeatedly posted findings referencing \`6e72f45b4\` while current HEAD was \`89f8c2c59\`. The consistent pattern: (1) identify the base commit the review ran against, (2) diff against current HEAD, (3) mark stale findings as already-resolved rather than re-fixing, (4) only action findings that are genuinely current. Also: distinguish transient infra failures (CodeQL, dependency-review — not in CI Status gate) from real substantive failures, and proceed with merge when only infra-only checks fail. - -* **Always verify CI on the latest commit and separate known flakes from regressions**: The user consistently expects CI status verification after every push to a PR branch, ensuring results reflect the newest commit (not stale). They schedule follow-up polls for critical jobs (Build Docs, E2E, preview, CI Status) and provide contingency directives: if docs/preview pass but E2E flakes due to a pre-existing race, treat it as non-blocking and investigate separately. Before merging, the user requires a full PR review, all security/required checks green (or only known transient infra failures), and confirmation the CI results are from the current head. Any real regression must be fixed (e.g., dependency bump) and CI re-run on the new commit before landing. - - -* **Always verify claims against concrete repository evidence**: Use repository files, command results, tests, and workflow configurations to validate conclusions rather than relying on assumptions. Report exact paths, line numbers, flags, artifact names, environment variables, and observed exit codes when relevant. Distinguish confirmed findings from hypotheses, reproduce behavioral discrepancies when possible, and honor explicit constraints—especially safety rules such as preventing network calls in dry-run mode. Prefer precise, exhaustive analysis over generalized summaries. + +* **Always verify explicit behavioral contracts with adversarial regression tests**: Treat the user’s stated invariants as hard requirements, especially for edge cases and output behavior. Trace every affected code path, check backward compatibility and stream/format contracts, and use precise tests that fail if the invariant is reverted. Prefer property-based tests when behavior must hold for all inputs. For reviews, remain read-only, inspect the actual diff and relevant surrounding code, report evidence with file and line references, distinguish findings by severity, and give a clear merge verdict. Preserve required runtime and tooling constraints, including checking package.json scripts before commands and using the mandated pnpm workflow. * **Always verify Sentry CLI auth and org access before querying issues**: Before running any \`sentry issue list\` or similar query, the user expects verification of: (1) authenticated identity and token expiry, (2) which orgs are accessible, and (3) which org/project the relevant telemetry flows into. If access is insufficient (e.g., 403 on internal org), the user switches accounts to gain access to the correct org (e.g., from \`ben@byk.im\` to \`byk@sentry.io\`). The user also wants proactive identification of which org/project maps to the code being debugged — never assume; always confirm before pulling issue data. @@ -507,8 +492,8 @@ * **Always write tests alongside implementation**: Behavioral pattern detected across 9 sessions (action: requested-tests). The user consistently demonstrates this behavior. - -* **bun add -d \ for dev dependencies (never omit -d)**: Directive (repeated across ~20 sessions): when adding a dev dependency in Bun-based repos (e.g. opencode, loreai CLI), always run \`bun add -d \\` — the \`-d\` flag installs into devDependencies, which is the default expectation. Do not omit the flag; do not use plain \`bun add \\` or \`bun install \\` for a dev dependency. + +* **API binary-safe output contracts**: API output contracts: Preserve successful binary responses as raw Uint8Array values byte-for-byte; never JSON-parse, JSON.stringify, or String() them, including TTY or JSON output. For binary errors, emit only HTTP status, content type, and byte count—never payload bytes or binary artifacts. Preserve statusText on non-2xx responses; textual empty errors include request context; JSON mode exposes {status, statusText, body}. Prompts and telemetry must never block scripted runs or interleave with structured stdout. * **Call plan\_exit to indicate planning done**: Always call plan\_exit to indicate that planning is done. @@ -522,11 +507,11 @@ * **Diagnose Craft release failures by inspecting workflow chain end-to-end**: When Craft-based releases fail (e.g., 'no commits since last release', missing GitHub releases), the user methodically inspects the entire pipeline: release.yml → publish.yml → ci.yml → .craft.yml, then cross-references CHANGELOG entries, git log commits, and version tags to reconstruct what Craft should have done. They list concrete corrective steps (e.g., 'add github target', 'tag commit as v0.3.1', 'rerun flow') rather than guessing. Follow this pattern: trace from workflow dispatch → craft prepare → artifact provider → targets → publish trigger; verify OIDC/publish\_repo wiring; map commits to versions via CHANGELOG before proposing fixes. - -* **End every plan-mode turn with question or plan\_exit (always)**: Directive (Burak Yigit Kaya, 2026-08-01, getsentry/cli dependabot task): always end plan-mode turns with either a question or \`plan\_exit\` — never leave a plan-mode response dangling. Prevents the agent from stalling in plan mode when the user expects transition to build mode. Reinforces \[\[019f60f0-2bff-7a84-8719-9ad240fffaef]]. Also: scheduled follow-ups for CI re-checks should reference specific job names and merge readiness criteria — not just generic recheck verbs. - -* **Evidence-based code review and verification**: Always verify code and PR claims against the actual implementation before accepting them. Read the real source and configuration at precise line numbers, quote relevant code or comments, cross-check automated tooling against its outputs, and confirm that an introduced fix addresses a problem that genuinely existed. Flag discrepancies between descriptions and implementation with precise, actionable findings rather than trusting assertions or summaries. +* **Evidence-based code, issue, and PR verification**: Always verify code, issue, dependency, and PR claims against the actual implementation before accepting them. Read the relevant source and configuration at precise locations, cross-check tooling claims against its outputs, and confirm that an alleged fix addresses a real problem. Do not trust summaries or PR descriptions over code; report discrepancies as precise, actionable findings with supporting evidence. + + +* **Exclude build dist analysis**: When running or configuring static analysis, especially Biome on very large files or when excessive-type/internal diagnostics appear, exclude generated \`build/\` and \`dist/\` directories from the analysis scope. Apply explicit ignore/include patterns as needed so generated artifacts do not cause performance failures or obscure relevant source diagnostics. * **Flaky test diagnosis workflow**: For intermittent test failures, ground the fix in evidence, not guesses. Get the full CI job log and failing test source; reconstruct timings/interleavings from log lines. Before patching, verify the flake pre-exists on unmodified code (clean leftover state like dist/, run from the correct package dir, e.g. packages/cli in pnpm monorepo). Run the suite repeatedly (4-6+ runs) to measure flakiness and capture a failing run. Instrument first — add diagnostic logging/lock traces to prove concurrency behavior at runtime before changing design. Run full vitest and tsc --noEmit after changes, and investigate Vitest deprecation warnings. Add a regression test for the specific bug and keep the fix minimal. @@ -534,8 +519,8 @@ * **Follow consistent code style conventions**: Behavioral pattern detected across 4 sessions (action: corrected-style). The user consistently demonstrates this behavior. - -* **Follow the established git workflow (branch, PR, review)**: Behavioral pattern detected across 12 sessions (action: enforced-workflow). The user consistently demonstrates this behavior. + +* **Git conflict staging explicit paths**: Always stage explicit file paths when resolving Git conflicts; never use \`git add -A\`. Broad staging looks convenient, but it can silently include generated, unrelated, or sensitive files in the commit. Explicit paths keep conflict resolution auditable and preserve minimal PR diffs. * **Ink import behavior**: User stated code should 'never calls \`import("ink")\` at runtime —', indicating a preference against runtime imports of Ink library. @@ -561,14 +546,20 @@ * **Never use node\_modules/**: User stated never to use 'node\_modules/.'. + +* **packages/cli JSDoc field contracts**: Always add JSDoc to exported functions, classes, and types, plus important internal symbols. Types and interfaces must document every property’s representation, units, permitted values, null meaning, and defaults. Chose complete contracts over terse type-only declarations because TypeScript syntax does not explain runtime units, sentinel values, or default behavior to CLI consumers and future maintainers. + + +* **pnpm add -D dev dependencies**: Always add packages with \`pnpm add -D \\` in this pnpm workspace, and keep every package in \`devDependencies\`. Chose devDependencies over dependencies because esbuild bundles all runtime code and \`pnpm run check:deps\` enforces this; regular dependencies look conventional but violate the shipped-CLI build model. + * **Prefer deterministic structural assertions over non-deterministic runtime checks for regression tests**: When the user reviews or writes regression tests, they reject flaky runtime-race detectors and demand deterministic structural assertions that prove the fix mechanically. Pattern: prefer an assertion that proves the contract holds by construction (e.g., after a close call returns, a sibling helper re-opens the path and verifies the returned fd number is strictly higher than the original — Linux fd allocation never recycles a lower unused number while a higher one is open, so the test cannot pass unless closeSync actually ran). The user explicitly overrides weaker approaches: in this session they replaced a /proc/self/fd readdir race check (0% bug-detection rate in vitest, 5.5% in standalone) with a structural fd-number comparison that is guaranteed-deterministic. Apply this: when proposing a regression test, design it so passing the test is logically equivalent to the fix being present, with no timing/scheduling dependence. * **Preference for deep SQLite understanding**: User repeatedly asks to understand HOW SQLite is used across multiple sessions, indicating a strong preference for deep technical understanding of database usage patterns. - -* **Prefers Bun-native APIs over Node**: prefer Bun-native APIs over Node. + +* **property-based tests input invariant**: When testing a property-based invariant, ensure it holds regardless of input rather than only for hand-picked examples. Example-based tests can pass while leaving edge cases uncovered; use the repository's property/model-based helpers and explicit arbitrary-input coverage when the behavior is intended to be universal. * **Push branch and open PR immediately after committing fixes, then hand off to CI/follow-ups**: After making a fix on a fix/\* branch, the user pushes the branch to origin and opens a PR (often via gh CLI) without manually merging or waiting locally. The assistant is expected to then shift focus to: (1) monitoring/waiting for CI on the opened PR, (2) identifying follow-up work in dependent repos (e.g., filing issues in BYK/binpatch when an action diverges), and (3) flagging unintended changes in the branch (such as auto-generated files like .lore.md) before proceeding. The user does not request local verification gates beyond running tests; pushing and opening the PR is the natural completion step for a fix branch. @@ -579,20 +570,17 @@ * **Run CI status checks with patience using polling loops rather than long single waits**: When polling CI status on PRs, the assistant repeatedly hits shell command timeouts (120000ms / 600000ms) while waiting for checks to complete. The user/assistant pattern is to retry with timeout rather than wait synchronously. To follow this pattern: when CI is polled and the command times out, retry the check with the same or extended timeout; break long CI waits into shorter polling intervals rather than a single long-running command. This avoids losing progress and lets the assistant respond incrementally as each check (e.g., docs, E2E, warden) completes. + +* **seriesColor Other muted fill**: For dashboard charts, \`seriesColor("Other", …)\` must always use muted gray and \`seriesFill("Other", …)\` must always return the lightest fill, \`░\`. Chose this fixed treatment over palette/index assignment because “Other” denotes residual aggregated data and must remain visually subordinate to named series. + -* **Strict PR merge-readiness workflow**: Drive every PR to a clean merge-ready state: branch, commit with .lore.md, push, then wait for CI and bot reviews. Rebase onto latest main and resolve conflicts; take HEAD for auto-generated .lore.md. Investigate CI failures by reading logs and conflict categories before fixing. Resolve every inline review thread (Bugbot, Seer, Warden): verify against current code, reply to stale/false positives, then resolve. Run full local verification (install, typecheck, lint, build, tests) and confirm required CI checks pass. After CI turns green, re-fetch PR state before merging — auto-merge may already have merged; verify merge commit has one parent. Final sweep for new comments, then squash-merge only when mergeStateStatus=CLEAN. +* **Strict PR merge-readiness workflow**: Drive every PR through the regular workflow: branch, commit, push, review, verify, then merge. Before merging, ensure every current review finding and bot comment is addressed or explicitly resolved; verify stale findings against the latest head. After each push, confirm CI is running for the exact latest commit, investigate failures from logs, and distinguish confirmed unrelated flakes or infrastructure failures from regressions. Re-fetch PR state, perform a final comment sweep, and merge only when required checks and mergeability are clean; if checks are pending, continue polling rather than merging early. * **Update configuration files systematically and verify paths after restructuring**: Always update configuration files systematically: update specific sections/blocks methodically (e.g., paths-filter globs, version-read), with detailed examination before edits. Applies to CI/CD configs (ci.yml, docs-preview.yml, eval-skill-fork.yml), with paths verified after repository restructuring (e.g., packages/cli/, apps/cli-docs/) and absolute paths used where necessary. - -* **Verify all review comments are addressed and CI checks pass before merging PRs**: User consistently requires thorough pre-merge verification: (1) all inline review comments from Bugbot, Seer, and Warden must be addressed or explicitly tracked, not just CI checks passing; (2) wait for all CI checks (including warden and E2E tests, not just Bugbot/Seer) to complete before merging; (3) review bot comment threads across merged PRs to confirm no unaddressed findings remain. Before approving any merge, the user expects: a complete check of all review threads, resolution status of every comment (B/S/N severity), and full CI green status. Apply this rigor — don't merge based on partial CI pass or assume bot comments are resolved without explicit verification. - - -* **Verify all review findings and end-to-end behavior before declaring work complete**: Before considering a merge or release done, the user insists on two checks: (1) all inline review threads, Bugbot findings, and Seer/Warden comments across every PR in the batch must be verified as addressed or explicitly accepted, not assumed resolved because the PR is merged; (2) the actual end-to-end flow (e.g., running \`sentry --verbose cli upgrade\`) must be exercised to confirm the upgrade works as intended. The user does not accept 'PR merged' as proof of completion. Pattern: when finalizing a batch of PRs/releases, enumerate unaddressed review items per PR first, address or defer each explicitly, then run the real upgrade/usage command to validate behavior before reporting done. - - -* **Verify PR descriptions against actual implementation before adoption**: When reviewing or adopting a dependency, PR, or action that claims to fix a specific bug or provide a specific behavior, the user always cross-references the claim against the actual code/config. Pattern observed: agent inspecting PR #1330 found its description falsely claimed a cross-major fix existed in the binpatch action, but verification of action.yml showed the fix was only in the workflow being deleted. Also confirmed by tracing wire contracts, threat model limits, and SHA-256 annotation semantics directly from source/docs rather than trusting summary claims. Action: read the source, not the PR body. - * **Verify pre-existing test failures by reproducing on main before treating as regressions**: When E2E test failures occur (especially in telemetry-exit.test.ts, bundle.test.ts, library.test.ts, or any bundle/race-condition related test), the user expects the assistant to first verify whether the failure reproduces on the main branch (without the user's PR change) before concluding it is a regression. Pattern observed across instances: 1. Run tests, observe failures (e.g., telemetry-exit timing, bundle 'Bundle not built' errors). 2. Check if change touches relevant code — if not, reproduce on main. 3. If failures reproduce on main, classify as pre-existing/flaky/infrastructure issue, not caused by the PR. 4. Proceed with merge once failure is confirmed unrelated. This avoids spurious investigations into unrelated flaky tests and unblocks the PR. Apply this verification step whenever a test failure appears and the PR's diff clearly doesn't touch the failing code path (e.g., workflow-only changes vs. test setup failures). + + +* **withTelemetry non-blocking drain**: Telemetry emission must never block CLI execution. \`withTelemetry()\` may perform best-effort deferred draining, but queue-flush failures must be swallowed so command results, scripted runs, and structured stdout are unaffected; disabled telemetry still invokes the callback with \`undefined\`. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 83c2c85ec..2b6898ef2 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -40,6 +40,7 @@ View a dashboard **Flags:** - `-w, --web - Open in browser` +- `-s, --sixel - Render the dashboard as a sixel image` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` - `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01"` diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 39390699c..1806fa469 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -54,6 +54,7 @@ type ViewFlags = { readonly period?: TimeRange; readonly json: boolean; readonly fields?: string[]; + readonly sixel: boolean; }; /** @@ -107,7 +108,7 @@ function buildViewData( }, widgetResults: Map, widgets: DashboardWidget[], - opts: { period: string; url: string } + opts: { period: string; url: string; sixel: boolean } ): DashboardViewData { return { id: dashboard.id, @@ -117,6 +118,7 @@ function buildViewData( url: opts.url, dateCreated: dashboard.dateCreated, environment: dashboard.environment, + sixel: opts.sixel, widgets: widgets.map((w, i) => ({ title: w.title, displayType: w.displayType, @@ -188,6 +190,11 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + sixel: { + kind: "boolean", + brief: "Render the dashboard as a sixel image", + default: false, + }, fresh: FRESH_FLAG, refresh: { kind: "parsed", @@ -203,7 +210,13 @@ export const viewCommand = buildCommand({ optional: true, }, }, - aliases: { ...FRESH_ALIASES, w: "web", r: "refresh", t: "period" }, + aliases: { + ...FRESH_ALIASES, + w: "web", + s: "sixel", + r: "refresh", + t: "period", + }, }, async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); @@ -281,6 +294,7 @@ export const viewCommand = buildCommand({ const viewData = buildViewData(dashboard, widgetData, widgets, { period: formatTimeRangeFlag(timeRange), url, + sixel: flags.sixel, }); if (!isFirstRender) { @@ -312,6 +326,7 @@ export const viewCommand = buildCommand({ buildViewData(dashboard, widgetData, widgets, { period: formatTimeRangeFlag(timeRange), url, + sixel: flags.sixel, }) ); return { hint: `Dashboard: ${url}` }; diff --git a/packages/cli/src/lib/formatters/chart-core.ts b/packages/cli/src/lib/formatters/chart-core.ts new file mode 100644 index 000000000..92d49d826 --- /dev/null +++ b/packages/cli/src/lib/formatters/chart-core.ts @@ -0,0 +1,345 @@ +/** + * Shared timeseries chart core. + * + * Turns a {@link TimeseriesResult} into a resolution-independent + * {@link ChartModel}, then rasterizes that model into an RGBA pixel canvas. + * Both the sixel renderer (pixel resolution) and the ASCII renderer + * (character-cell resolution) consume this single core so the two paths + * agree on layout, palette, and stacking. The output resolution is chosen by + * the target and fed in upfront via {@link rasterizeChart}. + */ + +import type { TimeseriesResult } from "../../types/dashboard.js"; +import type { DecodedImage } from "../sixel-image.js"; +import { createPixelCanvas, drawPixelRect } from "./pixel-canvas.js"; +import { downsample } from "./sparkline.js"; + +/** + * Chart color palette based on Sentry's categorical chart hues. + * + * Derived from sentry/static/app/utils/theme/scraps/tokens/color.tsx + * (categorical.dark / categorical.light), adjusted to a mid-luminance range + * so every color achieves ≥3:1 contrast on both dark (#1e1e1e) and light + * (#f0f0f0) terminal backgrounds. "Other" always gets muted gray. + */ +export const SERIES_PALETTE = [ + "#7553FF", // blurple (Sentry primary) + "#F0369A", // pink + "#C06F20", // orange (darkened from #FF9838) + "#3D8F09", // green (darkened from #67C800) + "#8B6AC8", // purple (lightened from #5D3EB2) + "#E45560", // salmon (darkened from #FA6769) + "#B82D90", // magenta + "#9E8B18", // yellow (darkened from #FFD00E) + "#228A83", // teal (fills hue gap) + "#7B50D0", // indigo (lightened from #50219C) +] as const; + +/** Muted gray for the "Other" bucket. */ +export const OTHER_COLOR = "#888888"; + +/** Get the hex color for a series by index. "Other" gets muted gray. */ +export function seriesColor(label: string, index: number): string { + if (label === "Other") { + return OTHER_COLOR; + } + return SERIES_PALETTE[index % SERIES_PALETTE.length] ?? SERIES_PALETTE[0]; +} + +/** Parse an RGB hex color into a 3-tuple. */ +export function hexToRgb(hex: string): [number, number, number] { + const normalized = hex.replace("#", ""); + if (normalized.length === 3) { + const r0 = normalized[0]; + const g0 = normalized[1]; + const b0 = normalized[2]; + if (r0 && g0 && b0) { + return [ + Number.parseInt(r0 + r0, 16), + Number.parseInt(g0 + g0, 16), + Number.parseInt(b0 + b0, 16), + ]; + } + } + return [ + Number.parseInt(normalized.slice(0, 2), 16), + Number.parseInt(normalized.slice(2, 4), 16), + Number.parseInt(normalized.slice(4, 6), 16), + ]; +} + +/** One series in a chart model: a label plus its per-bucket values. */ +export type ChartSeries = { + label: string; + values: number[]; +}; + +/** + * Resolution-independent chart description. + * + * `buckets` is the number of time buckets (columns). `maxVal` is the + * axis maximum: the largest single value for a single series, or the largest + * per-bucket total for a stacked chart. `stacked` records whether the columns + * are drawn as stacked segments (multi-series) or as plain bars (single). + */ +export type ChartModel = { + /** Source shape: timeseries buckets or independently sized categories. */ + kind: "timeseries" | "categorical"; + series: ChartSeries[]; + buckets: number; + maxVal: number; + stacked: boolean; +}; + +/** Build a resolution-independent chart model from a timeseries result. */ +export function buildChartModel( + data: TimeseriesResult +): ChartModel | undefined { + if ( + data.series.length === 0 || + data.series.every((s) => s.values.length === 0) + ) { + return; + } + + const series: ChartSeries[] = data.series.map((s) => ({ + label: s.label, + values: s.values.map((v) => v.value), + })); + const buckets = Math.max(...series.map((s) => s.values.length)); + const stacked = series.length > 1; + + const maxVal = stacked + ? Math.max(...bucketTotals(series, buckets), 1) + : Math.max(...(series[0]?.values ?? []), 1); + + return { kind: "timeseries", series, buckets, maxVal, stacked }; +} + +/** Build a bar-per-category model from a categorical timeseries result. */ +export function buildCategoricalChartModel( + data: TimeseriesResult +): ChartModel | undefined { + const series = data.series + .map((item) => ({ + label: item.label, + values: [item.values.reduce((total, value) => total + value.value, 0)], + })) + .sort((a, b) => { + if (a.label === "Other") { + return 1; + } + if (b.label === "Other") { + return -1; + } + return (b.values[0] ?? 0) - (a.values[0] ?? 0); + }); + if (series.length === 0) { + return; + } + + // "Other" can dwarf every real category. Match the text renderer by scaling + // against real categories first and clipping Other to the chart height. + const nonOther = series.filter((item) => item.label !== "Other"); + const scaleSeries = nonOther.length > 0 ? nonOther : series; + const maxVal = Math.max(...scaleSeries.map((item) => item.values[0] ?? 0), 1); + return { + kind: "categorical", + series, + buckets: series.length, + maxVal, + stacked: false, + }; +} + +/** Sum each bucket across every series. */ +function bucketTotals(series: ChartSeries[], buckets: number): number[] { + const totals = new Array(buckets).fill(0); + for (const s of series) { + for (let i = 0; i < buckets; i++) { + const total = totals[i]; + const value = s.values[i]; + if (total !== undefined && value !== undefined) { + totals[i] = total + value; + } + } + } + return totals; +} + +/** RGB for the default background when transparency is off. */ +const BACKGROUND_RGB: [number, number, number] = [30, 30, 30]; + +/** Options for {@link rasterizeChart}. */ +export type RasterizeOpts = { + /** Target canvas width in pixels. */ + width: number; + /** Target canvas height in pixels. */ + height: number; + /** Leave the background transparent instead of filling it. */ + backgroundTransparent?: boolean; +}; + +/** + * Rasterize a chart model into an RGBA pixel canvas at the given resolution. + * + * This is the pixel core: the resolution is chosen by the caller for its + * output target (sixel cell pixels, or an ASCII cell-grid multiple). Returns + * `undefined` when the model has no buckets to draw. + */ +export function rasterizeChart( + model: ChartModel, + opts: RasterizeOpts +): DecodedImage | undefined { + const width = Math.max(16, Math.floor(opts.width)); + const height = Math.max(8, Math.floor(opts.height)); + if (model.buckets === 0) { + return; + } + + // Each column needs at least a 1px bar plus a 1px gap, so more buckets than + // ~half the canvas width would push later columns off-canvas and clip them. + // Downsample to fit, mirroring what the ASCII sparkline path already does. + const fitted = fitModelToWidth(model, width); + + const transparent = opts.backgroundTransparent ?? true; + const img = createPixelCanvas({ + width, + height, + background: transparent ? undefined : BACKGROUND_RGB, + }); + const layout = computeBarLayout(width, fitted.buckets); + + if (fitted.kind === "categorical") { + drawCategoricalBars(img, fitted, height, layout); + } else if (fitted.stacked) { + drawStackedColumns(img, fitted, height, layout); + } else { + drawBars(img, fitted, height, layout); + } + + return img; +} + +/** + * Downsample a model's series so the bucket count fits the canvas: with a 1px + * bar and 1px gap each column needs ~2px, so cap buckets at `width / 2`. + * Returns the model unchanged when it already fits. + */ +function fitModelToWidth(model: ChartModel, width: number): ChartModel { + const maxBuckets = Math.max(1, Math.floor(width / 2)); + if (model.buckets <= maxBuckets || model.kind === "categorical") { + return model; + } + const series = model.series.map((s) => ({ + label: s.label, + values: downsample(s.values, maxBuckets), + })); + const buckets = Math.max(...series.map((s) => s.values.length)); + return { ...model, series, buckets }; +} + +/** Gap and bar width for evenly distributed columns. */ +type BarLayout = { + gap: number; + barWidth: number; +}; + +/** Compute the gap and width for evenly distributed bars. */ +function computeBarLayout(width: number, count: number): BarLayout { + const gap = Math.max(1, Math.floor(width / count / 8)); + const barWidth = Math.max(1, Math.floor((width - (count - 1) * gap) / count)); + return { gap, barWidth }; +} + +/** Draw single-series bars into the canvas. */ +function drawBars( + img: DecodedImage, + model: ChartModel, + height: number, + layout: BarLayout +): void { + const series = model.series[0]; + if (!series) { + return; + } + const color = hexToRgb(seriesColor(series.label, 0)); + + for (let i = 0; i < series.values.length; i++) { + const value = series.values[i] ?? 0; + const h = Math.round((value / model.maxVal) * height); + const x0 = i * (layout.barWidth + layout.gap); + drawPixelRect(img, { + x: x0, + y: height - h, + width: layout.barWidth, + height: h, + color, + }); + } +} + +/** Draw stacked multi-series columns into the canvas. */ +function drawStackedColumns( + img: DecodedImage, + model: ChartModel, + height: number, + layout: BarLayout +): void { + for (let b = 0; b < model.buckets; b++) { + const x0 = b * (layout.barWidth + layout.gap); + let yBottom = height; + + for (let s = 0; s < model.series.length; s++) { + const series = model.series[s]; + if (!series) { + continue; + } + const value = series.values[b] ?? 0; + if (value <= 0 || yBottom <= 0) { + continue; + } + + const segmentHeight = Math.min( + yBottom, + Math.max(1, Math.round((value / model.maxVal) * height)) + ); + const yTop = Math.max(0, yBottom - segmentHeight); + drawPixelRect(img, { + x: x0, + y: yTop, + width: layout.barWidth, + height: yBottom - yTop, + color: hexToRgb(seriesColor(series.label, s)), + }); + yBottom = yTop; + } + } +} + +/** Draw one independently scaled bar for every category. */ +function drawCategoricalBars( + image: DecodedImage, + model: ChartModel, + height: number, + layout: BarLayout +): void { + for (let index = 0; index < model.series.length; index += 1) { + const series = model.series[index]; + if (!series) { + continue; + } + const value = series.values[0] ?? 0; + const barHeight = Math.min( + height, + Math.max(0, Math.round((value / model.maxVal) * height)) + ); + drawPixelRect(image, { + x: index * (layout.barWidth + layout.gap), + y: height - barHeight, + width: layout.barWidth, + height: barHeight, + color: hexToRgb(seriesColor(series.label, index)), + }); + } +} diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 4889e647c..406418bd3 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -19,11 +19,18 @@ import type { TimeseriesResult, WidgetDataResult, } from "../../types/dashboard.js"; +import { getEnv } from "../env.js"; +import { + canRenderSixel, + terminalPixelHeight, + terminalPixelWidth, +} from "../sixel.js"; +import { SERIES_PALETTE } from "./chart-core.js"; import { COLORS, muted, terminalLink } from "./colors.js"; import { renderMarkdown } from "./markdown.js"; - import type { HumanRenderer } from "./output.js"; import { isPlainOutput } from "./plain-detect.js"; +import { renderDashboardAsSixel } from "./sixel-dashboard.js"; import { downsample, sparkline } from "./sparkline.js"; // --------------------------------------------------------------------------- @@ -39,6 +46,8 @@ export type DashboardViewData = { url: string; dateCreated?: string; environment?: string[]; + /** Per-invocation sixel opt-in from `dashboard view --sixel`. */ + sixel?: boolean; widgets: DashboardViewWidget[]; }; @@ -85,6 +94,12 @@ function getTermWidth(): number { return DEFAULT_TERM_WIDTH; } +/** Actual terminal width for pixel-exact sixel output, without ASCII's floor. */ +function getSixelTermWidth(): number | undefined { + const columns = process.stdout.columns; + return columns && columns > 0 ? columns : undefined; +} + // --------------------------------------------------------------------------- // Big number ASCII art font // --------------------------------------------------------------------------- @@ -1210,29 +1225,6 @@ function renderTimeBarRows( return rows; } -/** - * Chart color palette based on Sentry's categorical chart hues. - * - * Derived from sentry/static/app/utils/theme/scraps/tokens/color.tsx - * (categorical.dark / categorical.light), adjusted to a mid-luminance - * range so every color achieves ≥3:1 contrast on **both** dark (#1e1e1e) - * and light (#f0f0f0) terminal backgrounds. - * - * "Other" always gets muted gray (handled by seriesColor). - */ -const SERIES_PALETTE = [ - "#7553FF", // blurple (Sentry primary) - "#F0369A", // pink - "#C06F20", // orange (darkened from #FF9838) - "#3D8F09", // green (darkened from #67C800) - "#8B6AC8", // purple (lightened from #5D3EB2) - "#E45560", // salmon (darkened from #FA6769) - "#B82D90", // magenta - "#9E8B18", // yellow (darkened from #FFD00E) - "#228A83", // teal (fills hue gap) - "#7B50D0", // indigo (lightened from #50219C) -] as const; - /** * Fill characters for plain/no-color mode. * @@ -1241,7 +1233,13 @@ const SERIES_PALETTE = [ */ const PLAIN_FILLS = ["█", "▓", "▒", "#", "=", "*", "+", "~", ":", "."] as const; -/** Get the color for a series by index. "Other" gets muted gray. */ +/** + * Get the color for a series by index. "Other" gets muted gray. + * + * Shares {@link SERIES_PALETTE} with the pixel chart core so the ASCII and + * sixel renderers use identical hues; only the "Other" bucket differs (ANSI + * muted vs the core's hex gray). + */ function seriesColor(label: string, index: number): string { if (label === "Other") { return COLORS.muted; @@ -1537,8 +1535,10 @@ function renderPlaceholderContent(message: string): string[] { * Returns raw content lines (no title, no border). The caller handles * border wrapping and height enforcement. */ +type ContentWidget = Pick; + function renderContentLines(opts: { - widget: DashboardViewWidget; + widget: ContentWidget; innerWidth: number; contentHeight: number; }): string[] { @@ -1546,7 +1546,7 @@ function renderContentLines(opts: { const { data } = widget; switch (data.type) { - case "timeseries": + case "timeseries": { if (widget.displayType === "categorical_bar") { return renderVerticalBarsContent(data, { innerWidth, contentHeight }); } @@ -1555,6 +1555,7 @@ function renderContentLines(opts: { return renderTimeseriesBarsContent(data, { innerWidth, contentHeight }); } return renderTimeseriesContent(data, innerWidth); + } case "table": return renderTableContent(data, innerWidth); @@ -1616,7 +1617,7 @@ function renderWidgetLines( * If longer, it is truncated (ANSI-aware via character iteration). */ /** ANSI escape sequence type for the truncation state machine. */ -type EscapeType = "none" | "start" | "csi" | "osc"; +type EscapeType = "none" | "start" | "csi" | "osc" | "dcs"; /** Check if a character is an ASCII letter (CSI sequence terminator). */ function isAsciiLetter(ch: string): boolean { @@ -1635,6 +1636,15 @@ function advanceEscape( ch: string, buffer: string ): boolean { + return advanceEscapeInner(state, ch, buffer.at(-1)); +} + +function advanceEscapeInner( + state: { type: EscapeType }, + ch: string, + prev: string | undefined +): boolean { + const stTerminator = ch === "\\" && prev === "\x1b"; switch (state.type) { case "none": if (ch === "\x1b") { @@ -1647,6 +1657,8 @@ function advanceEscape( state.type = "csi"; } else if (ch === "]") { state.type = "osc"; + } else if (ch === "P") { + state.type = "dcs"; } else { state.type = "none"; } @@ -1658,7 +1670,13 @@ function advanceEscape( return true; case "osc": // OSC ends at BEL (\x07) or ST (\x1b\\) - if (ch === "\x07" || (ch === "\\" && buffer.at(-1) === "\x1b")) { + if (ch === "\x07" || stTerminator) { + state.type = "none"; + } + return true; + case "dcs": + // DCS ends at ST (\x1b\\) + if (stTerminator) { state.type = "none"; } return true; @@ -1675,7 +1693,7 @@ function fitToWidth(line: string, targetWidth: number): string { // Truncate: walk characters, tracking visible width let result = ""; let width = 0; - const esc = { type: "none" as "none" | "start" | "csi" | "osc" }; + const esc: { type: EscapeType } = { type: "none" }; for (const ch of line) { if (advanceEscape(esc, ch, result)) { result += ch; @@ -1840,11 +1858,59 @@ export function formatDashboardWithData(data: DashboardViewData): string { const termWidth = getTermWidth(); const lines: string[] = []; lines.push(...renderHeader(data, termWidth)); - lines.push(...renderGrid(data.widgets, termWidth)); + + const sixel = renderCompleteDashboardAsSixel(data, getSixelTermWidth()); + if (sixel) { + lines.push(sixel); + } else { + lines.push(...renderGrid(data.widgets, termWidth)); + } + lines.push(""); return lines.join("\n"); } +/** + * Render the complete dashboard as one sixel canvas only when the terminal + * exposes both cell dimensions. A sixel-only feature must never partially + * replace the framebuffer: unavailable geometry always returns the complete + * established character rendering. + */ +function renderCompleteDashboardAsSixel( + data: DashboardViewData, + termWidth: number | undefined +): string | undefined { + const env = getEnv(); + const optedIn = + data.sixel === true || + env.SENTRY_DASHBOARD_SIXEL === "1" || + data.widgets.some((widget) => widget.displayType === "timeseries_sixel"); + if (!(optedIn && termWidth) || isPlainOutput() || !canRenderSixel()) { + return; + } + const pixelWidth = terminalPixelWidth(termWidth); + const cellHeight = terminalPixelHeight(1); + if (!(pixelWidth && cellHeight)) { + return; + } + const cellWidth = Math.floor(pixelWidth / termWidth); + if (cellWidth < 1) { + return; + } + return renderDashboardAsSixel(data, { + pixelWidth, + cellWidth, + cellHeight, + renderTextContent(widget, innerWidth, contentHeight) { + return renderContentLines({ + widget, + innerWidth, + contentHeight, + }); + }, + }); +} + // --------------------------------------------------------------------------- // HumanRenderer factory (supports --refresh mode) // --------------------------------------------------------------------------- diff --git a/packages/cli/src/lib/formatters/index.ts b/packages/cli/src/lib/formatters/index.ts index 51c2381bc..adca23422 100644 --- a/packages/cli/src/lib/formatters/index.ts +++ b/packages/cli/src/lib/formatters/index.ts @@ -14,6 +14,7 @@ export * from "./markdown.js"; export * from "./numbers.js"; export * from "./output.js"; export * from "./seer.js"; +export * from "./sixel-timeseries.js"; export * from "./sparkline.js"; export * from "./table.js"; export * from "./time-utils.js"; diff --git a/packages/cli/src/lib/formatters/pixel-canvas.ts b/packages/cli/src/lib/formatters/pixel-canvas.ts new file mode 100644 index 000000000..e7fd83799 --- /dev/null +++ b/packages/cli/src/lib/formatters/pixel-canvas.ts @@ -0,0 +1,276 @@ +/** + * Small dependency-free primitives for composing sixel raster output. + * + * Charts and dashboards use this module to draw into the RGBA buffers consumed + * by the sixel encoder. The text renderer is deliberately limited to a compact + * terminal-sized bitmap font: unsupported glyphs remain visible as a fallback + * box instead of silently disappearing from a dashboard image. + */ + +import type { DecodedImage } from "../sixel-image.js"; + +/** An RGB color tuple with one 0-255 value per channel. */ +export type Rgb = [number, number, number]; + +/** Options for creating a pixel canvas. */ +export type CreatePixelCanvasOptions = { + /** Canvas width in pixels. */ + width: number; + /** Canvas height in pixels. */ + height: number; + /** Optional opaque background fill. A missing value leaves pixels transparent. */ + background?: Rgb; +}; + +/** Options for drawing a filled rectangle. */ +export type PixelRectOptions = { + /** Left edge in pixels. */ + x: number; + /** Top edge in pixels. */ + y: number; + /** Rectangle width in pixels. */ + width: number; + /** Rectangle height in pixels. */ + height: number; + /** Fill color. */ + color: Rgb; +}; + +/** Options for drawing one line of terminal-sized bitmap text. */ +export type PixelTextOptions = { + /** Left edge in pixels. */ + x: number; + /** Top edge in pixels. */ + y: number; + /** Width of one terminal cell in pixels. */ + cellWidth: number; + /** Height of one terminal cell in pixels. */ + cellHeight: number; + /** Maximum number of terminal cells to draw. */ + maxColumns: number; + /** Glyph color. */ + color: Rgb; +}; + +/** Create an RGBA pixel canvas, optionally initialized to an opaque color. */ +export function createPixelCanvas( + options: CreatePixelCanvasOptions +): DecodedImage { + const width = Math.max(1, Math.floor(options.width)); + const height = Math.max(1, Math.floor(options.height)); + const data = new Uint8Array(width * height * 4); + if (options.background) { + const [r, g, b] = options.background; + for (let i = 0; i < data.length; i += 4) { + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = 255; + } + } + return { width, height, data }; +} + +/** Draw a filled rectangle, clipping it to the canvas bounds. */ +export function drawPixelRect( + image: DecodedImage, + options: PixelRectOptions +): void { + const x0 = Math.max(0, Math.floor(options.x)); + const y0 = Math.max(0, Math.floor(options.y)); + const x1 = Math.min(image.width, Math.ceil(options.x + options.width)); + const y1 = Math.min(image.height, Math.ceil(options.y + options.height)); + const [r, g, b] = options.color; + + for (let y = y0; y < y1; y += 1) { + for (let x = x0; x < x1; x += 1) { + const offset = (y * image.width + x) * 4; + image.data[offset] = r; + image.data[offset + 1] = g; + image.data[offset + 2] = b; + image.data[offset + 3] = 255; + } + } +} + +/** Copy non-transparent source pixels into a destination image. */ +export function blitPixelImage( + destination: DecodedImage, + source: DecodedImage, + x: number, + y: number +): void { + const destX = Math.floor(x); + const destY = Math.floor(y); + const firstSourceX = Math.max(0, -destX); + const lastSourceX = Math.min(source.width, destination.width - destX); + const firstSourceY = Math.max(0, -destY); + const lastSourceY = Math.min(source.height, destination.height - destY); + + for (let sourceY = firstSourceY; sourceY < lastSourceY; sourceY += 1) { + const targetY = destY + sourceY; + for (let sourceX = firstSourceX; sourceX < lastSourceX; sourceX += 1) { + const targetX = destX + sourceX; + copyOpaquePixel( + destination, + source, + { x: sourceX, y: sourceY }, + { + x: targetX, + y: targetY, + } + ); + } + } +} + +/** A pixel position in an image buffer. */ +type PixelPoint = { x: number; y: number }; + +/** Copy one source pixel when it is not transparent. */ +function copyOpaquePixel( + destination: DecodedImage, + source: DecodedImage, + sourcePoint: PixelPoint, + targetPoint: PixelPoint +): void { + const { x: sourceX, y: sourceY } = sourcePoint; + const { x: targetX, y: targetY } = targetPoint; + const sourceOffset = (sourceY * source.width + sourceX) * 4; + if ((source.data[sourceOffset + 3] ?? 0) === 0) { + return; + } + const targetOffset = (targetY * destination.width + targetX) * 4; + destination.data[targetOffset] = source.data[sourceOffset] ?? 0; + destination.data[targetOffset + 1] = source.data[sourceOffset + 1] ?? 0; + destination.data[targetOffset + 2] = source.data[sourceOffset + 2] ?? 0; + destination.data[targetOffset + 3] = source.data[sourceOffset + 3] ?? 255; +} + +/** 5x7 glyphs for dashboard labels and table content. */ +const FONT: Record = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"], + G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], + I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], + J: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"], + K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"], + N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], + Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], + V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"], + W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"], + X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"], + Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"], + Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"], + "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"], + "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"], + "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"], + "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"], + "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"], + "5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"], + "6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"], + "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"], + "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], + "9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"], + " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"], + ".": ["00000", "00000", "00000", "00000", "00000", "00110", "00110"], + ",": ["00000", "00000", "00000", "00000", "00110", "00110", "00100"], + ":": ["00000", "00110", "00110", "00000", "00110", "00110", "00000"], + ";": ["00000", "00110", "00110", "00000", "00110", "00110", "00100"], + "-": ["00000", "00000", "00000", "01110", "00000", "00000", "00000"], + _: ["00000", "00000", "00000", "00000", "00000", "00000", "11111"], + "/": ["00001", "00010", "00100", "01000", "10000", "00000", "00000"], + "\\": ["10000", "01000", "00100", "00010", "00001", "00000", "00000"], + "(": ["00010", "00100", "01000", "01000", "01000", "00100", "00010"], + ")": ["01000", "00100", "00010", "00010", "00010", "00100", "01000"], + "[": ["01110", "01000", "01000", "01000", "01000", "01000", "01110"], + "]": ["01110", "00010", "00010", "00010", "00010", "00010", "01110"], + "=": ["00000", "11111", "00000", "11111", "00000", "00000", "00000"], + "+": ["00000", "00100", "00100", "11111", "00100", "00100", "00000"], + "?": ["01110", "10001", "00001", "00010", "00100", "00000", "00100"], + "!": ["00100", "00100", "00100", "00100", "00100", "00000", "00100"], + "#": ["01010", "11111", "01010", "01010", "11111", "01010", "00000"], + "%": ["11001", "11010", "00100", "01000", "10110", "00110", "00000"], + "@": ["01110", "10001", "10111", "10101", "10111", "10000", "01111"], + "*": ["00000", "10101", "01110", "11111", "01110", "10101", "00000"], + "|": ["00100", "00100", "00100", "00100", "00100", "00100", "00100"], + "<": ["00010", "00100", "01000", "10000", "01000", "00100", "00010"], + ">": ["01000", "00100", "00010", "00001", "00010", "00100", "01000"], + "&": ["01100", "10010", "10100", "01000", "10101", "10010", "01101"], + "'": ["00100", "00100", "00010", "00000", "00000", "00000", "00000"], + '"': ["01010", "01010", "00100", "00000", "00000", "00000", "00000"], + "█": ["11111", "11111", "11111", "11111", "11111", "11111", "11111"], + "■": ["01110", "11111", "11111", "11111", "11111", "11111", "01110"], +}; + +/** Visible fallback for a Unicode glyph outside the compact bitmap font. */ +const UNKNOWN_GLYPH = [ + "11111", + "10001", + "10101", + "10101", + "10101", + "10001", + "11111", +]; + +/** Draw a single line of compact bitmap text, clipped to its terminal cells. */ +export function drawPixelText( + image: DecodedImage, + text: string, + options: PixelTextOptions +): void { + const scale = Math.max( + 1, + Math.min( + Math.floor((options.cellWidth - 2) / 5), + Math.floor((options.cellHeight - 2) / 7) + ) + ); + + let column = 0; + for (const rawChar of text) { + if (column >= options.maxColumns) { + break; + } + const glyph = FONT[rawChar.toUpperCase()] ?? UNKNOWN_GLYPH; + const glyphWidth = 5 * scale; + const glyphHeight = 7 * scale; + const glyphX = + options.x + + column * options.cellWidth + + Math.max(0, Math.floor((options.cellWidth - glyphWidth) / 2)); + const glyphY = + options.y + + Math.max(0, Math.floor((options.cellHeight - glyphHeight) / 2)); + + for (let row = 0; row < glyph.length; row += 1) { + const pattern = glyph[row] ?? "00000"; + for (let pixel = 0; pixel < pattern.length; pixel += 1) { + if (pattern[pixel] === "1") { + drawPixelRect(image, { + x: glyphX + pixel * scale, + y: glyphY + row * scale, + width: scale, + height: scale, + color: options.color, + }); + } + } + } + column += 1; + } +} diff --git a/packages/cli/src/lib/formatters/sixel-dashboard.ts b/packages/cli/src/lib/formatters/sixel-dashboard.ts new file mode 100644 index 000000000..0306f548a --- /dev/null +++ b/packages/cli/src/lib/formatters/sixel-dashboard.ts @@ -0,0 +1,499 @@ +/** + * Full-dashboard sixel compositor. + * + * A sixel sequence advances the terminal cursor, so individual images cannot + * safely participate in the character framebuffer. This module rasterizes the + * complete dashboard grid into one positioned image instead. Every widget is + * therefore rendered by the same sixel output path, including neighboring + * widgets on the same grid row. + */ + +import type { WidgetDataResult } from "../../types/dashboard.js"; +import { encodeImageToSixel } from "../sixel-image.js"; +import { + buildCategoricalChartModel, + buildChartModel, + hexToRgb, + rasterizeChart, + seriesColor, +} from "./chart-core.js"; +import { + blitPixelImage, + createPixelCanvas, + drawPixelRect, + drawPixelText, + type Rgb, +} from "./pixel-canvas.js"; + +/** Number of dashboard grid columns. */ +const GRID_COLUMNS = 6; + +/** Terminal rows occupied by one dashboard grid-height unit. */ +const LINES_PER_GRID_UNIT = 6; + +/** Prevent a pathological dashboard from producing an unbounded DCS payload. */ +const MAX_CANVAS_PIXELS = 8_000_000; + +/** Muted frame color that works on light and dark terminal backgrounds. */ +const FRAME_COLOR: Rgb = [128, 128, 128]; + +/** Default text color for title, tables, and other non-chart content. */ +const TEXT_COLOR: Rgb = [224, 224, 224]; + +/** ANSI CSI, OSC, and DCS escape sequences emitted by text formatters. */ +const TERMINAL_ESCAPE_RE = new RegExp( + `${String.fromCharCode(27)}(?:\\[[0-?]*[ -/]*[@-~]|\\][\\s\\S]*?(?:${String.fromCharCode(7)}|${String.fromCharCode(27)}\\\\)|P[\\s\\S]*?${String.fromCharCode(27)}\\\\)`, + "g" +); + +/** A widget layout used by the dashboard grid. */ +export type SixelWidgetLayout = { + /** Left grid column. */ + x: number; + /** Top grid row. */ + y: number; + /** Width in grid columns. */ + w: number; + /** Height in grid units. */ + h: number; +}; + +/** Minimum dashboard widget fields needed by the sixel compositor. */ +export type SixelDashboardWidget = { + /** Widget title displayed in the top frame line. */ + title: string; + /** Sentry display type, used to distinguish categorical bars. */ + displayType: string; + /** Grid position when supplied by the dashboard API. */ + layout?: SixelWidgetLayout; + /** Fully resolved query result. */ + data: WidgetDataResult; +}; + +/** Minimum dashboard fields needed by the sixel compositor. */ +export type SixelDashboardData = { + /** Resolved widgets to compose. */ + widgets: SixelDashboardWidget[]; +}; + +/** Options supplied by the terminal capability layer and dashboard formatter. */ +export type RenderSixelDashboardOptions = { + /** Complete dashboard width in device pixels. */ + pixelWidth: number; + /** Width of one terminal cell in device pixels. */ + cellWidth: number; + /** Height of one terminal cell in device pixels. */ + cellHeight: number; + /** Render text-only widget content in terminal-cell lines. */ + renderTextContent: ( + widget: SixelDashboardWidget, + innerWidth: number, + contentHeight: number + ) => string[]; +}; + +/** A widget paired with the layout used for the final composite. */ +type PositionedWidget = { + widget: SixelDashboardWidget; + layout: SixelWidgetLayout; +}; + +/** Render every dashboard widget into one terminal-positioned sixel image. */ +export function renderDashboardAsSixel( + data: SixelDashboardData, + options: RenderSixelDashboardOptions +): string | undefined { + const widgets = positionWidgets(data.widgets); + if (widgets.length === 0) { + return; + } + + const pixelWidth = Math.max(1, Math.floor(options.pixelWidth)); + const gridHeight = Math.max( + ...widgets.map((item) => item.layout.y + item.layout.h) + ); + const pixelHeight = gridHeight * LINES_PER_GRID_UNIT * options.cellHeight; + if (pixelWidth * pixelHeight > MAX_CANVAS_PIXELS) { + return; + } + + const image = createPixelCanvas({ width: pixelWidth, height: pixelHeight }); + for (const positioned of widgets) { + drawWidget(image, positioned, options); + } + // The canvas is already bounded by MAX_CANVAS_PIXELS and must match the + // terminal width exactly to preserve the dashboard grid. + return encodeImageToSixel(image, image.width, true); +} + +/** Place layout-less widgets beneath the explicit dashboard grid. */ +function positionWidgets(widgets: SixelDashboardWidget[]): PositionedWidget[] { + let nextY = Math.max( + 0, + ...widgets.flatMap((widget) => + widget.layout ? [widget.layout.y + widget.layout.h] : [] + ) + ); + + return widgets.map((widget) => { + if (widget.layout) { + return { widget, layout: widget.layout }; + } + const layout = { x: 0, y: nextY, w: GRID_COLUMNS, h: 1 }; + nextY += layout.h; + return { widget, layout }; + }); +} + +/** Draw one fully-contained dashboard widget into the composite canvas. */ +function drawWidget( + image: ReturnType, + positioned: PositionedWidget, + options: RenderSixelDashboardOptions +): void { + const { widget, layout } = positioned; + const x = Math.floor((layout.x / GRID_COLUMNS) * image.width); + const y = layout.y * LINES_PER_GRID_UNIT * options.cellHeight; + const width = Math.max( + 1, + Math.floor((layout.w / GRID_COLUMNS) * image.width) + ); + const height = Math.max( + 1, + layout.h * LINES_PER_GRID_UNIT * options.cellHeight + ); + drawFrame(image, { x, y, width, height, title: widget.title, options }); + + const contentX = x + options.cellWidth; + const contentY = y + options.cellHeight; + const contentWidth = Math.max(1, width - 2 * options.cellWidth); + const contentHeight = Math.max(1, height - 2 * options.cellHeight); + + if (widget.data.type === "timeseries") { + drawChartContent(image, { + data: widget.data, + categorical: widget.displayType === "categorical_bar", + x: contentX, + y: contentY, + width: contentWidth, + height: contentHeight, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + }); + return; + } + + const innerWidth = Math.max(1, Math.floor(contentWidth / options.cellWidth)); + const lineCount = Math.max(1, Math.floor(contentHeight / options.cellHeight)); + const lines = options.renderTextContent(widget, innerWidth, lineCount); + for (let row = 0; row < Math.min(lineCount, lines.length); row += 1) { + drawPixelText(image, stripTerminalEscapes(lines[row] ?? ""), { + x: contentX, + y: contentY + row * options.cellHeight, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: innerWidth, + color: TEXT_COLOR, + }); + } +} + +/** Draw a single-pixel widget frame and its title. */ +function drawFrame( + image: ReturnType, + options: { + x: number; + y: number; + width: number; + height: number; + title: string; + options: RenderSixelDashboardOptions; + } +): void { + const { x, y, width, height, title } = options; + drawPixelRect(image, { x, y, width, height: 1, color: FRAME_COLOR }); + drawPixelRect(image, { + x, + y: y + height - 1, + width, + height: 1, + color: FRAME_COLOR, + }); + drawPixelRect(image, { x, y, width: 1, height, color: FRAME_COLOR }); + drawPixelRect(image, { + x: x + width - 1, + y, + width: 1, + height, + color: FRAME_COLOR, + }); + drawPixelText(image, title, { + x: x + options.options.cellWidth, + y, + cellWidth: options.options.cellWidth, + cellHeight: options.options.cellHeight, + maxColumns: Math.max(1, Math.floor(width / options.options.cellWidth) - 2), + color: TEXT_COLOR, + }); +} + +/** Draw chart bars, axes, and a series legend into a widget's content region. */ +function drawChartContent( + image: ReturnType, + options: { + data: Extract; + categorical: boolean; + x: number; + y: number; + width: number; + height: number; + cellWidth: number; + cellHeight: number; + } +): void { + const model = options.categorical + ? buildCategoricalChartModel(options.data) + : buildChartModel(options.data); + if (!model) { + drawPixelText(image, "(NO DATA)", { + x: options.x, + y: options.y, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: Math.max(1, Math.floor(options.width / options.cellWidth)), + color: FRAME_COLOR, + }); + return; + } + + const contentRows = Math.max( + 1, + Math.floor(options.height / options.cellHeight) + ); + const hasLegend = contentRows >= 4; + const hasAxisLabels = contentRows >= 5; + const gutterColumns = options.width >= options.cellWidth * 12 ? 5 : 0; + const gutterWidth = gutterColumns * options.cellWidth; + const footerHeight = + (hasLegend ? 1 : 0) * options.cellHeight + + (hasAxisLabels ? 1 : 0) * options.cellHeight; + const chartX = options.x + gutterWidth; + const chartWidth = options.width - gutterWidth; + const chartHeight = options.height - footerHeight; + // rasterizeChart enforces a 16x8 minimum, which must never escape this + // widget's allocated rectangle on exceptionally narrow terminals. + if (chartWidth < 16 || chartHeight < 8) { + return; + } + const chart = rasterizeChart(model, { + width: chartWidth, + height: chartHeight, + backgroundTransparent: true, + }); + if (!chart) { + return; + } + + blitPixelImage(image, chart, chartX, options.y); + drawPixelRect(image, { + x: chartX, + y: options.y + chartHeight - 1, + width: chartWidth, + height: 1, + color: FRAME_COLOR, + }); + if (gutterWidth > 0) { + drawPixelRect(image, { + x: chartX, + y: options.y, + width: 1, + height: chartHeight, + color: FRAME_COLOR, + }); + drawPixelText( + image, + formatChartValue(model.maxVal, options.data.series[0]?.unit), + { + x: options.x, + y: options.y, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: gutterColumns, + color: FRAME_COLOR, + } + ); + drawPixelText(image, "0", { + x: options.x, + y: options.y + chartHeight - options.cellHeight, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: gutterColumns, + color: FRAME_COLOR, + }); + } + + let footerY = options.y + chartHeight; + if (hasAxisLabels) { + drawChartLabels(image, { + modelKind: model.kind, + series: model.series, + data: options.data, + x: chartX, + y: footerY, + width: chartWidth, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + }); + footerY += options.cellHeight; + } + if (hasLegend) { + drawLegend(image, { + series: model.series, + x: options.x, + y: footerY, + width: options.width, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + }); + } +} + +/** Draw a concise first/last x-axis label for time and categorical charts. */ +function drawChartLabels( + image: ReturnType, + options: { + modelKind: "timeseries" | "categorical"; + series: { label: string; values: number[] }[]; + data: Extract; + x: number; + y: number; + width: number; + cellWidth: number; + cellHeight: number; + } +): void { + const columns = Math.max(1, Math.floor(options.width / options.cellWidth)); + const firstTimestamp = options.data.series[0]?.values[0]?.timestamp; + const lastTimestamp = options.data.series[0]?.values.at(-1)?.timestamp; + const spanDays = + typeof firstTimestamp === "number" && typeof lastTimestamp === "number" + ? (lastTimestamp - firstTimestamp) / (24 * 60 * 60) + : 0; + const first = + options.modelKind === "categorical" + ? options.series[0]?.label + : formatTimestamp(firstTimestamp, spanDays); + const last = + options.modelKind === "categorical" + ? options.series.at(-1)?.label + : formatTimestamp(lastTimestamp, spanDays); + drawPixelText(image, first ?? "", { + x: options.x, + y: options.y, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: Math.max(1, Math.floor(columns / 2)), + color: FRAME_COLOR, + }); + const lastColumns = Math.min(Math.floor(columns / 2), (last ?? "").length); + drawPixelText(image, last ?? "", { + x: options.x + Math.max(0, columns - lastColumns) * options.cellWidth, + y: options.y, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: Math.max(1, lastColumns), + color: FRAME_COLOR, + }); +} + +/** Draw colored series keys and labels, clipping safely to the widget width. */ +function drawLegend( + image: ReturnType, + options: { + series: { label: string; values: number[] }[]; + x: number; + y: number; + width: number; + cellWidth: number; + cellHeight: number; + } +): void { + let column = 0; + const maxColumns = Math.max(1, Math.floor(options.width / options.cellWidth)); + for (let index = 0; index < options.series.length; index += 1) { + const series = options.series[index]; + if (!series || column >= maxColumns) { + break; + } + const label = series.label.slice(0, 12); + const requiredColumns = Math.min(maxColumns, label.length + 2); + if (column + requiredColumns > maxColumns) { + break; + } + drawPixelRect(image, { + x: options.x + column * options.cellWidth, + y: options.y + Math.max(1, Math.floor(options.cellHeight / 3)), + width: Math.max(2, Math.floor(options.cellWidth / 2)), + height: Math.max(2, Math.floor(options.cellHeight / 3)), + color: hexToRgb(seriesColor(series.label, index)), + }); + drawPixelText(image, label, { + x: options.x + (column + 1) * options.cellWidth, + y: options.y, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: Math.min(label.length, maxColumns - column - 1), + color: TEXT_COLOR, + }); + column += requiredColumns; + } +} + +/** Format a number compactly enough for the chart-axis gutter. */ +function formatChartValue( + value: number, + unit: string | null | undefined +): string { + const formatted = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); + return unit ? `${formatted} ${unit}` : formatted; +} + +/** Format timestamps using the same span-aware form as the character dashboard. */ +export function formatTimestamp( + timestamp: number | undefined, + spanDays: number +): string { + if (timestamp === undefined) { + return ""; + } + const date = new Date(timestamp * 1000); + if (spanDays < 2) { + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; + } + if (spanDays <= 30) { + return `${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}`; + } + const months = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + return `${months[date.getMonth()] ?? "???"} ${date.getDate()}`; +} + +/** Remove terminal formatting sequences before drawing text into a bitmap. */ +function stripTerminalEscapes(value: string): string { + return value.replace(TERMINAL_ESCAPE_RE, ""); +} diff --git a/packages/cli/src/lib/formatters/sixel-timeseries.ts b/packages/cli/src/lib/formatters/sixel-timeseries.ts new file mode 100644 index 000000000..6df80358a --- /dev/null +++ b/packages/cli/src/lib/formatters/sixel-timeseries.ts @@ -0,0 +1,58 @@ +/** + * Timeseries → sixel chart renderer. + * + * Thin wrapper over the shared chart core (see {@link buildChartModel} and + * {@link rasterizeChart}): builds the resolution-independent model, rasterizes + * it at the caller's pixel resolution, then reuses the existing + * {@link encodeImageToSixel} encoder for a terminal-ready DCS escape sequence. + */ + +import type { TimeseriesResult } from "../../types/dashboard.js"; +import { encodeImageToSixel } from "../sixel-image.js"; +import { buildChartModel, rasterizeChart } from "./chart-core.js"; + +export type RenderSixelOpts = { + /** Maximum pixel width of the rendered chart. */ + maxPixelWidth?: number; + /** Maximum pixel height of the rendered chart. */ + maxPixelHeight?: number; + /** Leave the background transparent instead of filling it. */ + backgroundTransparent?: boolean; +}; + +/** Default chart bitmap dimensions. */ +const DEFAULT_WIDTH = 320; +const DEFAULT_HEIGHT = 120; + +/** + * Render a timeseries result as an inline sixel image. + * + * Returns a DCS sixel escape sequence, or `undefined` when the data is empty + * or the bitmap has no drawable pixels. + */ +export function renderTimeseriesAsSixel( + data: TimeseriesResult, + opts: RenderSixelOpts = {} +): string | undefined { + const { + maxPixelWidth = DEFAULT_WIDTH, + maxPixelHeight = DEFAULT_HEIGHT, + backgroundTransparent = true, + } = opts; + + const model = buildChartModel(data); + if (!model) { + return; + } + + const img = rasterizeChart(model, { + width: maxPixelWidth, + height: maxPixelHeight, + backgroundTransparent, + }); + if (!img) { + return; + } + + return encodeImageToSixel(img, img.width); +} diff --git a/packages/cli/src/lib/sixel-image.ts b/packages/cli/src/lib/sixel-image.ts index 295922cd9..ebfa46b37 100644 --- a/packages/cli/src/lib/sixel-image.ts +++ b/packages/cli/src/lib/sixel-image.ts @@ -527,19 +527,23 @@ function colorsInBand(plane: IndexedPlane, y0: number): number[] { * * @param img - Decoded RGBA image. * @param maxWidth - Cap on rendered pixel width; wider images are downscaled. - * The effective cap is the smaller of this and {@link DEFAULT_MAX_WIDTH}, so - * passing the terminal's pixel width keeps the image from overflowing while - * still bounding the escape-sequence size. Omit to use the default ceiling. + * Omit to use the default ceiling. + * @param preserveDimensions - Preserve explicitly supplied dimensions above the + * default ceilings. Callers must bound image dimensions first. */ export function encodeImageToSixel( img: DecodedImage, - maxWidth?: number + maxWidth?: number, + preserveDimensions = false ): string | undefined { - const effectiveMaxWidth = Math.min( - maxWidth ?? DEFAULT_MAX_WIDTH, - DEFAULT_MAX_WIDTH + const effectiveMaxWidth = preserveDimensions + ? (maxWidth ?? DEFAULT_MAX_WIDTH) + : Math.min(maxWidth ?? DEFAULT_MAX_WIDTH, DEFAULT_MAX_WIDTH); + const scaled = downscale( + img, + effectiveMaxWidth, + preserveDimensions ? img.height : DEFAULT_MAX_HEIGHT ); - const scaled = downscale(img, effectiveMaxWidth, DEFAULT_MAX_HEIGHT); const palette = buildPalette(scaled, PALETTE_SIZE); if (palette.length === 0) { return; diff --git a/packages/cli/src/lib/sixel.ts b/packages/cli/src/lib/sixel.ts index dcfaa8259..811255a42 100644 --- a/packages/cli/src/lib/sixel.ts +++ b/packages/cli/src/lib/sixel.ts @@ -225,6 +225,23 @@ export function terminalPixelWidth( return columns * caps.cellWidth; } +/** + * The usable image height in device pixels for a number of terminal rows. + * + * Returns `undefined` when the terminal did not report cell height. Callers + * rendering positioned sixel layouts must require this measurement rather than + * guessing, because a guessed row height corrupts the dashboard grid. + */ +export function terminalPixelHeight( + rows: number = process.stdout.rows ?? 24 +): number | undefined { + const caps = detectSixelCaps(); + if (!(caps.supported && caps.cellHeight && caps.cellHeight > 0)) { + return; + } + return rows * caps.cellHeight; +} + /** * The baked sixel banner escape string when the terminal supports sixel and the * image fits `columns`; otherwise `undefined` so the caller falls back to the diff --git a/packages/cli/test/lib/formatters/chart-core.test.ts b/packages/cli/test/lib/formatters/chart-core.test.ts new file mode 100644 index 000000000..c442bc2d7 --- /dev/null +++ b/packages/cli/test/lib/formatters/chart-core.test.ts @@ -0,0 +1,220 @@ +/** + * Shared chart core tests. + */ + +import { describe, expect, test } from "vitest"; +import { + buildCategoricalChartModel, + buildChartModel, + hexToRgb, + rasterizeChart, + SERIES_PALETTE, + seriesColor, +} from "../../../src/lib/formatters/chart-core.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +describe("seriesColor", () => { + test("returns muted gray for the Other bucket", () => { + expect(seriesColor("Other", 3)).toBe("#888888"); + }); + + test("cycles through the palette by index", () => { + expect(seriesColor("a", 0)).toBe(SERIES_PALETTE[0]); + expect(seriesColor("a", SERIES_PALETTE.length)).toBe(SERIES_PALETTE[0]); + expect(seriesColor("a", 1)).toBe(SERIES_PALETTE[1]); + }); +}); + +describe("hexToRgb", () => { + test("parses six-digit hex", () => { + expect(hexToRgb("#7553FF")).toEqual([0x75, 0x53, 0xff]); + }); + + test("parses shorthand three-digit hex", () => { + expect(hexToRgb("#0f8")).toEqual([0x00, 0xff, 0x88]); + }); +}); + +describe("buildChartModel", () => { + test("returns undefined for empty series", () => { + expect(buildChartModel(makeTimeseries({ series: [] }))).toBeUndefined(); + }); + + test("returns undefined when every series is empty", () => { + const model = buildChartModel( + makeTimeseries({ + series: [ + { label: "a", values: [] }, + { label: "b", values: [] }, + ], + }) + ); + expect(model).toBeUndefined(); + }); + + test("builds a single-series, non-stacked model with peak maxVal", () => { + const model = buildChartModel(makeTimeseries()); + expect(model).toBeDefined(); + expect(model?.stacked).toBe(false); + expect(model?.kind).toBe("timeseries"); + expect(model?.buckets).toBe(4); + expect(model?.maxVal).toBe(30); + }); + + test("builds a stacked model with per-bucket totals as maxVal", () => { + const model = buildChartModel( + makeTimeseries({ + series: [ + { + label: "alpha", + values: [ + { timestamp: 1, value: 10 }, + { timestamp: 2, value: 20 }, + ], + }, + { + label: "beta", + values: [ + { timestamp: 1, value: 5 }, + { timestamp: 2, value: 10 }, + ], + }, + ], + }) + ); + expect(model?.stacked).toBe(true); + expect(model?.buckets).toBe(2); + // Largest per-bucket total is 20 + 10 = 30. + expect(model?.maxVal).toBe(30); + }); +}); + +describe("buildCategoricalChartModel", () => { + test("sorts category bars while keeping Other last and out of the scale", () => { + const model = buildCategoricalChartModel( + makeTimeseries({ + series: [ + { label: "Other", values: [{ timestamp: 1, value: 1000 }] }, + { label: "US", values: [{ timestamp: 1, value: 20 }] }, + { label: "GB", values: [{ timestamp: 1, value: 10 }] }, + ], + }) + ); + + expect(model?.kind).toBe("categorical"); + expect(model?.series.map((series) => series.label)).toEqual([ + "US", + "GB", + "Other", + ]); + expect(model?.maxVal).toBe(20); + }); +}); + +describe("rasterizeChart", () => { + test("returns a canvas at the requested resolution", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 64, height: 32 }); + expect(img).toBeDefined(); + expect(img?.width).toBe(64); + expect(img?.height).toBe(32); + expect(img?.data.length).toBe(64 * 32 * 4); + }); + + test("clamps resolution to a minimum size", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 1, height: 1 }); + expect(img?.width).toBe(16); + expect(img?.height).toBe(8); + }); + + test("draws opaque pixels for bars", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 64, height: 32 }); + let opaque = 0; + for (let i = 3; i < (img?.data.length ?? 0); i += 4) { + if ((img?.data[i] ?? 0) > 0) { + opaque += 1; + } + } + expect(opaque).toBeGreaterThan(0); + }); + + test("fills the background when transparency is off", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { + width: 32, + height: 16, + backgroundTransparent: false, + }); + // Top-left pixel is above the bars, so it shows the background fill. + expect(img?.data[3]).toBe(255); + }); + + test("downsamples dense series so late buckets stay on canvas", () => { + // Far more buckets than half the canvas width: without downsampling the + // rising tail would be clipped off the right edge. Put all the signal in + // the last quarter of the range so a clipped render would be near-empty. + const values = Array.from({ length: 400 }, (_, i) => ({ + timestamp: 1_700_000_000 + i * 60, + value: i < 300 ? 0 : i, + })); + const model = buildChartModel( + makeTimeseries({ series: [{ label: "c", values }] }) + ); + const width = 64; + const img = rasterizeChart(model!, { width, height: 32 }); + + // Count opaque pixels in the right quarter — the tail must survive. + let rightOpaque = 0; + const data = img?.data ?? new Uint8Array(); + for (let y = 0; y < 32; y++) { + for (let x = Math.floor(width * 0.75); x < width; x++) { + if ((data[(y * width + x) * 4 + 3] ?? 0) > 0) { + rightOpaque += 1; + } + } + } + expect(rightOpaque).toBeGreaterThan(0); + }); + + test("draws independent categorical bars instead of a stacked column", () => { + const model = buildCategoricalChartModel( + makeTimeseries({ + series: [ + { label: "alpha", values: [{ timestamp: 1, value: 10 }] }, + { label: "beta", values: [{ timestamp: 1, value: 20 }] }, + ], + }) + ); + const image = rasterizeChart(model!, { width: 40, height: 20 }); + const data = image?.data ?? new Uint8Array(); + const leftBarAlpha = data[(19 * 40 + 0) * 4 + 3] ?? 0; + const rightBarAlpha = data[(19 * 40 + 21) * 4 + 3] ?? 0; + const gapAlpha = data[(19 * 40 + 19) * 4 + 3] ?? 0; + + expect(leftBarAlpha).toBe(255); + expect(rightBarAlpha).toBe(255); + expect(gapAlpha).toBe(0); + }); +}); diff --git a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts new file mode 100644 index 000000000..ee511e84b --- /dev/null +++ b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts @@ -0,0 +1,300 @@ +/** + * Dashboard sixel integration tests. + * + * Stubs `canRenderSixel` and `terminalPixelWidth` so the dashboard formatter + * takes the sixel rendering path deterministically, then verifies that the + * output contains one sixel DCS sequence for the complete dashboard grid. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type DashboardViewData, + type DashboardViewWidget, + formatDashboardWithData, +} from "../../../src/lib/formatters/dashboard.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as sixelModule from "../../../src/lib/sixel.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +const ESC = "\x1b"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +function makeWidget( + overrides: Partial = {} +): DashboardViewWidget { + return { + title: "Test Widget", + displayType: "line", + data: makeTimeseries(), + ...overrides, + }; +} + +function makeDashboardData( + overrides: Partial = {} +): DashboardViewData { + return { + id: "12345", + title: "My Dashboard", + period: "24h", + fetchedAt: "2024-01-15T10:30:00Z", + url: "https://sentry.io/organizations/test-org/dashboard/12345/", + environment: ["production"], + widgets: [makeWidget()], + ...overrides, + }; +} + +describe("dashboard sixel integration", () => { + let savedSixelEnv: string | undefined; + let savedPlainOutput: string | undefined; + let savedColumns: number | undefined; + + beforeEach(() => { + savedSixelEnv = process.env.SENTRY_DASHBOARD_SIXEL; + savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + savedColumns = process.stdout.columns; + process.env.SENTRY_DASHBOARD_SIXEL = "1"; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + process.stdout.columns = 40; + vi.spyOn(sixelModule, "canRenderSixel").mockReturnValue(true); + vi.spyOn(sixelModule, "terminalPixelWidth").mockReturnValue(320); + vi.spyOn(sixelModule, "terminalPixelHeight").mockReturnValue(12); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (savedSixelEnv === undefined) { + delete process.env.SENTRY_DASHBOARD_SIXEL; + } else { + process.env.SENTRY_DASHBOARD_SIXEL = savedSixelEnv; + } + if (savedPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; + } + process.stdout.columns = savedColumns; + }); + + test("renders one sixel canvas for a timeseries widget when enabled", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + expect(output.split(`${ESC}P`)).toHaveLength(2); + expect(output).toContain('"1;1;320;144'); + }); + + test("uses displayType=timeseries_sixel as an opt-in signal", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Explicit Sixel", + displayType: "timeseries_sixel", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + // Disable the env flag so only the displayType triggers sixel rendering. + delete process.env.SENTRY_DASHBOARD_SIXEL; + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + expect(output.split(`${ESC}P`)).toHaveLength(2); + }); + + test("renders scalar and timeseries widgets in the same sixel canvas", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Big Number", + displayType: "big_number", + data: { type: "scalar", value: 42 }, + layout: { x: 0, y: 0, w: 3, h: 1 }, + }), + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 3, y: 0, w: 3, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + expect(output.split(`${ESC}P`)).toHaveLength(2); + expect(output).not.toContain("Big Number"); + expect(output).not.toContain("Sixel Chart"); + }); + + test("renders every non-chart widget type inside the sixel canvas", () => { + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [ + makeWidget({ + title: "Table Widget", + displayType: "table", + layout: { x: 0, y: 0, w: 3, h: 1 }, + data: { + type: "table", + columns: [{ name: "count" }], + rows: [{ count: 42 }], + }, + }), + makeWidget({ + title: "Text Widget", + displayType: "text", + layout: { x: 3, y: 0, w: 3, h: 1 }, + data: { type: "text", content: "Dashboard note" }, + }), + makeWidget({ + title: "Failed Widget", + displayType: "line", + layout: { x: 0, y: 1, w: 3, h: 1 }, + data: { type: "error", message: "Query failed" }, + }), + makeWidget({ + title: "Unsupported Widget", + displayType: "wheel", + layout: { x: 3, y: 1, w: 3, h: 1 }, + data: { type: "unsupported", reason: "Not implemented" }, + }), + ], + }) + ); + + expect(output.split(`${ESC}P`)).toHaveLength(2); + expect(output).not.toContain("Table Widget"); + expect(output).not.toContain("Dashboard note"); + expect(output).not.toContain("Query failed"); + expect(output).not.toContain("Unsupported Widget"); + }); + + test("renders categorical_bar widgets as sixel bars", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Categorical", + displayType: "categorical_bar", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + }); + + test("preserves side-by-side widget layout in one sixel canvas", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Big Number", + displayType: "big_number", + data: { type: "scalar", value: 42 }, + layout: { x: 0, y: 0, w: 3, h: 2 }, + }), + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 3, y: 0, w: 3, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + // A DCS sequence reserves one full 320x144 pixel dashboard grid, so two + // adjacent widgets always share their original row instead of serializing. + expect(output).toContain('"1;1;320;144'); + expect(output.split(`${ESC}P`)).toHaveLength(2); + }); + + test("preserves wide terminal canvas width", () => { + vi.mocked(sixelModule.terminalPixelWidth).mockReturnValue(1024); + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [makeWidget({ layout: { x: 0, y: 0, w: 6, h: 1 } })], + }) + ); + + expect(output).toContain('"1;1;1024;72'); + }); + + test("uses the actual narrow terminal width for the sixel canvas", () => { + vi.mocked(sixelModule.terminalPixelWidth).mockReturnValue(320); + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [makeWidget({ layout: { x: 0, y: 0, w: 6, h: 1 } })], + }) + ); + + expect(output).toContain('"1;1;320;72'); + }); + + test("keeps charts inside exceptionally narrow widget bounds", () => { + process.stdout.columns = 4; + vi.mocked(sixelModule.terminalPixelWidth).mockReturnValue(32); + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [ + makeWidget({ layout: { x: 0, y: 0, w: 1, h: 1 } }), + makeWidget({ + title: "Neighbor", + layout: { x: 1, y: 0, w: 1, h: 1 }, + }), + ], + }) + ); + + expect(output).toContain('"1;1;32;72'); + }); + + test("falls back to the complete character dashboard without cell geometry", () => { + vi.mocked(sixelModule.terminalPixelHeight).mockReturnValue(undefined); + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [ + makeWidget({ + title: "Fallback Widget", + layout: { x: 0, y: 0, w: 6, h: 1 }, + }), + ], + }) + ); + + expect(output).not.toContain(`${ESC}P`); + expect(output).toContain("Fallback Widget"); + }); +}); diff --git a/packages/cli/test/lib/formatters/sixel-dashboard.test.ts b/packages/cli/test/lib/formatters/sixel-dashboard.test.ts new file mode 100644 index 000000000..6abeddf60 --- /dev/null +++ b/packages/cli/test/lib/formatters/sixel-dashboard.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; +import { formatTimestamp } from "../../../src/lib/formatters/sixel-dashboard.js"; + +describe("formatTimestamp", () => { + const timestamp = Date.UTC(2024, 0, 15, 10, 30) / 1000; + const date = new Date(timestamp * 1000); + + test("uses clock time for periods shorter than two days", () => { + expect(formatTimestamp(timestamp, 1)).toBe( + `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}` + ); + }); + + test("formats the Unix epoch instead of treating it as missing", () => { + const epoch = new Date(0); + expect(formatTimestamp(0, 1)).toBe( + `${String(epoch.getHours()).padStart(2, "0")}:${String(epoch.getMinutes()).padStart(2, "0")}` + ); + expect(formatTimestamp(0, 7)).toBe( + `${String(epoch.getMonth() + 1).padStart(2, "0")}/${String(epoch.getDate()).padStart(2, "0")}` + ); + }); + + test("uses calendar dates for multi-day periods", () => { + expect(formatTimestamp(timestamp, 7)).toBe( + `${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}` + ); + expect(formatTimestamp(timestamp, 31)).toBe(`Jan ${date.getDate()}`); + }); +}); diff --git a/packages/cli/test/lib/formatters/sixel-timeseries.test.ts b/packages/cli/test/lib/formatters/sixel-timeseries.test.ts new file mode 100644 index 000000000..a68fec308 --- /dev/null +++ b/packages/cli/test/lib/formatters/sixel-timeseries.test.ts @@ -0,0 +1,109 @@ +/** + * Timeseries → sixel renderer tests. + */ + +import { describe, expect, test } from "vitest"; +import { renderTimeseriesAsSixel } from "../../../src/lib/formatters/sixel-timeseries.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +const ESC = "\x1b"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +describe("renderTimeseriesAsSixel", () => { + test("returns undefined when there are no series", () => { + const data = makeTimeseries({ series: [] }); + expect(renderTimeseriesAsSixel(data)).toBeUndefined(); + }); + + test("returns undefined when all series are empty", () => { + const data = makeTimeseries({ + series: [ + { label: "a", values: [] }, + { label: "b", values: [] }, + ], + }); + expect(renderTimeseriesAsSixel(data)).toBeUndefined(); + }); + + test("emits a DCS sixel sequence for a single series", () => { + const data = makeTimeseries(); + const sixel = renderTimeseriesAsSixel(data, { + maxPixelWidth: 64, + maxPixelHeight: 32, + }); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); + + test("emits a DCS sixel sequence for stacked multi-series", () => { + const data = makeTimeseries({ + series: [ + { + label: "alpha", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + ], + }, + { + label: "beta", + values: [ + { timestamp: 1_700_000_000, value: 5 }, + { timestamp: 1_700_000_060, value: 10 }, + ], + }, + ], + }); + const sixel = renderTimeseriesAsSixel(data, { + maxPixelWidth: 64, + maxPixelHeight: 32, + }); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); + + test("applies background fill when requested", () => { + const data = makeTimeseries(); + const transparent = renderTimeseriesAsSixel(data, { + maxPixelWidth: 32, + maxPixelHeight: 16, + backgroundTransparent: true, + }); + const opaque = renderTimeseriesAsSixel(data, { + maxPixelWidth: 32, + maxPixelHeight: 16, + backgroundTransparent: false, + }); + expect(transparent).toBeDefined(); + expect(opaque).toBeDefined(); + }); + + test("uses sensible defaults for missing options", () => { + const data = makeTimeseries(); + const sixel = renderTimeseriesAsSixel(data); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); +}); diff --git a/packages/cli/test/lib/sixel-image.test.ts b/packages/cli/test/lib/sixel-image.test.ts index 137f49eef..6b3e7b8f4 100644 --- a/packages/cli/test/lib/sixel-image.test.ts +++ b/packages/cli/test/lib/sixel-image.test.ts @@ -258,6 +258,18 @@ describe("encodeImageToSixel", () => { expect(sixel).toContain('"1;1;800;'); }); + test("preserves an explicitly bounded wide canvas", () => { + const img = solidImage(1024, 10, [10, 20, 30, 255]); + const sixel = encodeImageToSixel(img, 1024, true); + expect(sixel).toContain('"1;1;1024;'); + }); + + test("preserves an explicitly bounded tall canvas", () => { + const img = solidImage(10, 3000, [10, 20, 30, 255]); + const sixel = encodeImageToSixel(img, 10, true); + expect(sixel).toContain('"1;1;10;3000'); + }); + test("bounds the height of a narrow but very tall image", () => { // 40px wide (within budget) but 5000px tall — scaled uniformly to the // DEFAULT_MAX_HEIGHT (2000) so the escape sequence stays bounded. Width