From e53a1c072ada2ecd2b64e43937b45760a6fdaf9a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 11:31:07 +0000 Subject: [PATCH 01/52] feat(formatters): render all terminal output via marked-terminal Add marked-terminal as the rendering engine for all human-readable CLI output. All detail views (issue, event, org, project, log, trace, Seer) now build markdown internally and render through marked-terminal, producing Unicode box-drawing tables and styled headings. Streaming list output (log/trace rows) stays row-by-row for compatibility with follow/watch modes. - Add src/lib/formatters/markdown.ts with central renderMarkdown() utility - Rewrite formatters to return rendered string instead of string[] - Convert all detail sections to markdown tables (| **Label** | value | pattern) - Convert Seer root cause and solution output to fenced code blocks - Remove divider(), formatTable() dead code from human.ts - Escape SDK names in code spans to prevent markdown transformation of underscores - Update all formatter tests for new string return type and content-based checks --- bun.lock | 95 +++ package.json | 5 + src/commands/event/view.ts | 6 +- src/commands/issue/explain.ts | 3 +- src/commands/issue/list.ts | 3 +- src/commands/issue/plan.ts | 3 +- src/commands/issue/view.ts | 10 +- src/commands/log/view.ts | 3 +- src/commands/project/view.ts | 7 +- src/commands/trace/view.ts | 4 +- src/lib/formatters/human.ts | 694 +++++++++------------ src/lib/formatters/index.ts | 1 + src/lib/formatters/log.ts | 144 +++-- src/lib/formatters/markdown.ts | 90 +++ src/lib/formatters/output.ts | 9 +- src/lib/formatters/seer.ts | 140 ++--- src/lib/formatters/table.ts | 69 +- src/lib/formatters/trace.ts | 73 ++- test/commands/project/view.func.test.ts | 2 +- test/lib/formatters/human.details.test.ts | 247 ++++---- test/lib/formatters/human.utils.test.ts | 103 +-- test/lib/formatters/log.property.test.ts | 33 +- test/lib/formatters/log.test.ts | 68 +- test/lib/formatters/seer.test.ts | 87 +-- test/lib/formatters/table.test.ts | 63 +- test/lib/formatters/trace.property.test.ts | 26 +- test/lib/formatters/trace.test.ts | 32 +- 27 files changed, 986 insertions(+), 1034 deletions(-) create mode 100644 src/lib/formatters/markdown.ts diff --git a/bun.lock b/bun.lock index 2d6f6a6e..13b8be05 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "sentry", + "dependencies": { + "marked": "^15", + "marked-terminal": "^7.3.0", + }, "devDependencies": { "@biomejs/biome": "2.3.8", "@sentry/api": "^0.1.0", @@ -13,6 +17,7 @@ "@stricli/auto-complete": "^1.2.4", "@stricli/core": "^1.2.4", "@types/bun": "latest", + "@types/marked-terminal": "^6.1.1", "@types/node": "^22", "@types/qrcode-terminal": "^0.12.2", "@types/semver": "^7.7.1", @@ -95,6 +100,8 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -261,6 +268,8 @@ "@sentry/opentelemetry": ["@sentry/opentelemetry@10.39.0", "", { "dependencies": { "@sentry/core": "10.39.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-eU8t/pyxjy7xYt6PNCVxT+8SJw5E3pnupdcUNN4ClqG4O5lX4QCDLtId48ki7i30VqrLtR7vmCHMSvqXXdvXPA=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@stricli/auto-complete": ["@stricli/auto-complete@1.2.5", "", { "dependencies": { "@stricli/core": "^1.2.5" }, "bin": { "auto-complete": "dist/bin/cli.js" } }, "sha512-C6G88Hh4lUWBwiqsxbcA4I1ricSQwiLaOziTWW3NmBoX7WGTW7i7RvyooXMpZk1YMLf2olv5Odxmg127ik1DKQ=="], "@stricli/core": ["@stricli/core@1.2.5", "", {}, "sha512-+afyztQW7fwWkqmU2WQZbdc3LjnZThWYdtE0l+hykZ1Rvy7YGxZSvsVCS/wZ/2BNv117pQ9TU1GZZRIcPnB4tw=="], @@ -269,8 +278,12 @@ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@types/cardinal": ["@types/cardinal@2.1.1", "", {}, "sha512-/xCVwg8lWvahHsV2wXZt4i64H1sdL+sN1Uoq7fAc8/FA6uYHjuIveDwPwvGUYp4VZiv85dVl6J/Bum3NDAOm8g=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/marked-terminal": ["@types/marked-terminal@6.1.1", "", { "dependencies": { "@types/cardinal": "^2.1", "@types/node": "*", "chalk": "^5.3.0", "marked": ">=6.0.0 <12" } }, "sha512-DfoUqkmFDCED7eBY9vFUhJ9fW8oZcMAK5EwRDQ9drjTbpQa+DnBTQQCwWhTFVf4WsZ6yYcJTI8D91wxTWXRZZQ=="], + "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], @@ -291,6 +304,14 @@ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -313,12 +334,24 @@ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], + + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + + "cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -331,6 +364,12 @@ "electron-to-chromium": ["electron-to-chromium@1.5.278", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -351,10 +390,16 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -365,6 +410,8 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], @@ -385,6 +432,10 @@ "magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + + "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], + "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], @@ -393,6 +444,10 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -401,12 +456,18 @@ "nypm": ["nypm@0.6.4", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "p-limit": ["p-limit@7.2.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], @@ -443,12 +504,28 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -465,6 +542,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], + "unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -483,10 +562,18 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], + + "yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -501,16 +588,24 @@ "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + "@types/marked-terminal/marked": ["marked@11.2.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "bun-types/@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ultracite/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="], diff --git a/package.json b/package.json index bccb6693..0e3be15a 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@stricli/auto-complete": "^1.2.4", "@stricli/core": "^1.2.4", "@types/bun": "latest", + "@types/marked-terminal": "^6.1.1", "@types/node": "^22", "@types/qrcode-terminal": "^0.12.2", "@types/semver": "^7.7.1", @@ -63,5 +64,9 @@ "packageManager": "bun@1.3.9", "patchedDependencies": { "@stricli/core@1.2.5": "patches/@stricli%2Fcore@1.2.5.patch" + }, + "dependencies": { + "marked": "^15", + "marked-terminal": "^7.3.0" } } diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 7efcfca4..1e62ea5f 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -54,11 +54,7 @@ type HumanOutputOptions = { function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void { const { event, detectedFrom, spanTreeLines } = options; - const lines = formatEventDetails(event, `Event ${event.eventID}`); - - // Skip leading empty line for standalone display - const output = lines.slice(1); - stdout.write(`${output.join("\n")}\n`); + stdout.write(`${formatEventDetails(event, `Event ${event.eventID}`)}\n`); if (spanTreeLines && spanTreeLines.length > 0) { stdout.write(`${spanTreeLines.join("\n")}\n`); diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index baf8a467..ac091b53 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -107,8 +107,7 @@ export const explainCommand = buildCommand({ } // Human-readable output - const lines = formatRootCauseList(causes); - stdout.write(`${lines.join("\n")}\n`); + stdout.write(`${formatRootCauseList(causes)}\n`); writeFooter( stdout, `To create a plan, run: sentry issue plan ${issueArg}` diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 773890df..4e21b2f4 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -35,7 +35,6 @@ import { ValidationError, } from "../../lib/errors.js"; import { - divider, type FormatShortIdOptions, formatIssueListHeader, formatIssueRow, @@ -118,7 +117,7 @@ function writeListHeader( ): void { stdout.write(`${title}:\n\n`); stdout.write(muted(`${formatIssueListHeader(isMultiProject)}\n`)); - stdout.write(`${divider(isMultiProject ? 96 : 80)}\n`); + stdout.write(muted(`${"─".repeat(isMultiProject ? 96 : 80)}\n`)); } /** Issue with formatting options attached */ diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 48ad529f..20d55e0c 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -125,8 +125,7 @@ function outputSolution(options: OutputSolutionOptions): void { } if (solution) { - const lines = formatSolution(solution); - stdout.write(`${lines.join("\n")}\n`); + stdout.write(`${formatSolution(solution)}\n`); } else { stderr.write("No solution found. Check the Sentry web UI for details.\n"); } diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index a11b5d09..744ec0c2 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -57,17 +57,13 @@ type HumanOutputOptions = { function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void { const { issue, event, spanTreeLines } = options; - const issueLines = formatIssueDetails(issue); - stdout.write(`${issueLines.join("\n")}\n`); + stdout.write(`${formatIssueDetails(issue)}\n`); if (event) { // Pass issue permalink for constructing replay links - const eventLines = formatEventDetails( - event, - "Latest Event", - issue.permalink + stdout.write( + `${formatEventDetails(event, "Latest Event", issue.permalink)}\n` ); - stdout.write(`${eventLines.join("\n")}\n`); } if (spanTreeLines && spanTreeLines.length > 0) { diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 5c0a23d3..f3518c6a 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -92,8 +92,7 @@ function writeHumanOutput( orgSlug: string, detectedFrom?: string ): void { - const lines = formatLogDetails(log, orgSlug); - stdout.write(`${lines.join("\n")}\n`); + stdout.write(`${formatLogDetails(log, orgSlug)}\n`); if (detectedFrom) { stdout.write(`\nDetected from ${detectedFrom}\n`); diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 8aed1b5f..f245f519 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -15,7 +15,6 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { AuthError, ContextError } from "../../lib/errors.js"; import { - divider, formatProjectDetails, writeJson, writeOutput, @@ -183,13 +182,11 @@ function writeMultipleProjects( const target = targets[i]; if (i > 0) { - stdout.write(`\n${divider(60)}\n\n`); + stdout.write(`\n${"─".repeat(60)}\n\n`); } if (project) { - const details = formatProjectDetails(project, dsn); - stdout.write(details.join("\n")); - stdout.write("\n"); + stdout.write(`${formatProjectDetails(project, dsn)}\n`); if (target?.detectedFrom) { stdout.write(`\nDetected from: ${target.detectedFrom}\n`); } diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 1f39d2c0..23841afb 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -96,13 +96,13 @@ export type ResolvedTraceTarget = { export function writeHumanOutput( stdout: Writer, options: { - summaryLines: string[]; + summaryLines: string; spanTreeLines?: string[]; } ): void { const { summaryLines, spanTreeLines } = options; - stdout.write(`${summaryLines.join("\n")}\n`); + stdout.write(`${summaryLines}\n`); if (spanTreeLines && spanTreeLines.length > 0) { stdout.write(`${spanTreeLines.join("\n")}\n`); diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 365e103f..9f70a901 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -2,14 +2,15 @@ * Human-readable output formatters * * Centralized formatting utilities for consistent CLI output. - * Follows gh cli patterns for alignment and presentation. + * Detail views (issue, event, org, project) are built as markdown and rendered + * via renderMarkdown(). List rows still use lightweight inline formatting for + * performance, while list tables are rendered via writeTable() → renderMarkdown(). */ // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/bun"; import prettyMs from "pretty-ms"; import type { - Breadcrumb, BreadcrumbsEntry, ExceptionEntry, ExceptionValue, @@ -27,26 +28,24 @@ import { boldUnderline, type FixabilityTier, fixabilityColor, - green, levelColor, muted, - red, statusColor, - yellow, } from "./colors.js"; +import { renderMarkdown } from "./markdown.js"; // Status Formatting const STATUS_ICONS: Record = { - resolved: green("✓"), - unresolved: yellow("●"), - ignored: muted("−"), + resolved: "✓", + unresolved: "●", + ignored: "−", }; const STATUS_LABELS: Record = { - resolved: `${green("✓")} Resolved`, - unresolved: `${yellow("●")} Unresolved`, - ignored: `${muted("−")} Ignored`, + resolved: "✓ Resolved", + unresolved: "● Unresolved", + ignored: "− Ignored", }; /** Maximum features to display before truncating with "... and N more" */ @@ -148,47 +147,24 @@ function extractEntry( const BASE_URL_REGEX = /^(https?:\/\/[^/]+)/; /** - * Format a features array for display, truncating if necessary. + * Format a features list as a markdown bullet list. * * @param features - Array of feature names (may be undefined) - * @returns Formatted lines to append to output, or empty array if no features + * @returns Markdown string, or empty string if no features */ -function formatFeaturesList(features: string[] | undefined): string[] { +function formatFeaturesMarkdown(features: string[] | undefined): string { if (!features || features.length === 0) { - return []; + return ""; } - const lines: string[] = ["", `Features (${features.length}):`]; const displayFeatures = features.slice(0, MAX_DISPLAY_FEATURES); - lines.push(` ${displayFeatures.join(", ")}`); - - if (features.length > MAX_DISPLAY_FEATURES) { - lines.push(` ... and ${features.length - MAX_DISPLAY_FEATURES} more`); - } - - return lines; -} + const items = displayFeatures.map((f) => `- ${f}`).join("\n"); + const more = + features.length > MAX_DISPLAY_FEATURES + ? `\n*... and ${features.length - MAX_DISPLAY_FEATURES} more*` + : ""; -/** Minimum width for header separator line */ -const MIN_HEADER_WIDTH = 20; - -/** - * Format a details header with slug and name. - * Handles empty values gracefully. - * - * @param slug - Resource slug (e.g., org or project slug) - * @param name - Resource display name - * @returns Array with header line and separator - */ -function formatDetailsHeader(slug: string, name: string): [string, string] { - const displaySlug = slug || "(no slug)"; - const displayName = name || "(unnamed)"; - const header = `${displaySlug}: ${displayName}`; - const separatorWidth = Math.max( - MIN_HEADER_WIDTH, - Math.min(80, header.length) - ); - return [header, muted("═".repeat(separatorWidth))]; + return `\n**Features** (${features.length}):\n\n${items}${more}`; } /** @@ -214,59 +190,6 @@ export function formatStatusLabel(status: string | undefined): string { ); } -// Table Formatting - -type TableColumn = { - header: string; - width: number; - align?: "left" | "right"; -}; - -/** - * Format a table with aligned columns - */ -export function formatTable( - columns: TableColumn[], - rows: string[][] -): string[] { - const lines: string[] = []; - - // Header - const header = columns - .map((col) => - col.align === "right" - ? col.header.padStart(col.width) - : col.header.padEnd(col.width) - ) - .join(" "); - lines.push(header); - - // Rows - for (const row of rows) { - const formatted = row - .map((cell, i) => { - const col = columns[i]; - if (!col) { - return cell; - } - return col.align === "right" - ? cell.padStart(col.width) - : cell.padEnd(col.width); - }) - .join(" "); - lines.push(formatted); - } - - return lines; -} - -/** - * Create a horizontal divider - */ -export function divider(length = 80, char = "─"): string { - return muted(char.repeat(length)); -} - // Date Formatting /** @@ -658,319 +581,275 @@ export function formatIssueRow( } /** - * Format detailed issue information + * Format detailed issue information as rendered markdown. + * + * @param issue - The Sentry issue to format + * @returns Rendered terminal string */ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: issue formatting logic -export function formatIssueDetails(issue: SentryIssue): string[] { +export function formatIssueDetails(issue: SentryIssue): string { const lines: string[] = []; - // Header - lines.push(`${issue.shortId}: ${issue.title}`); - lines.push( - muted( - "═".repeat(Math.min(80, issue.title.length + issue.shortId.length + 2)) - ) - ); + lines.push(`## ${issue.shortId}: ${issue.title}`); lines.push(""); - // Status with substatus - let statusLine = formatStatusLabel(issue.status); - if (issue.substatus) { - statusLine += ` (${capitalize(issue.substatus)})`; - } - lines.push(`Status: ${statusLine}`); + // Key-value details as a table + const rows: string[] = []; + + rows.push( + `| **Status** | ${formatStatusLabel(issue.status)}${issue.substatus ? ` (${capitalize(issue.substatus)})` : ""} |` + ); - // Priority if (issue.priority) { - lines.push(`Priority: ${capitalize(issue.priority)}`); + rows.push(`| **Priority** | ${capitalize(issue.priority)} |`); } - // Seer fixability if ( issue.seerFixabilityScore !== null && issue.seerFixabilityScore !== undefined ) { - const fixDetail = formatFixabilityDetail(issue.seerFixabilityScore); const tier = getSeerFixabilityLabel(issue.seerFixabilityScore); - lines.push(`Fixability: ${fixabilityColor(fixDetail, tier)}`); + const fixDetail = formatFixabilityDetail(issue.seerFixabilityScore); + rows.push(`| **Fixability** | ${fixabilityColor(fixDetail, tier)} |`); } - // Level with unhandled indicator let levelLine = issue.level ?? "unknown"; if (issue.isUnhandled) { levelLine += " (unhandled)"; } - lines.push(`Level: ${levelLine}`); + rows.push(`| **Level** | ${levelLine} |`); + rows.push(`| **Platform** | ${issue.platform ?? "unknown"} |`); + rows.push(`| **Type** | ${issue.type ?? "unknown"} |`); + rows.push(`| **Assignee** | ${issue.assignedTo?.name ?? "Unassigned"} |`); - lines.push(`Platform: ${issue.platform ?? "unknown"}`); - lines.push(`Type: ${issue.type ?? "unknown"}`); - - // Assignee (show early, it's important) - const assigneeName = issue.assignedTo?.name ?? "Unassigned"; - lines.push(`Assignee: ${assigneeName}`); - lines.push(""); - - // Project if (issue.project) { - lines.push(`Project: ${issue.project.name} (${issue.project.slug})`); + rows.push( + `| **Project** | ${issue.project.name} (\`${issue.project.slug}\`) |` + ); } - // Releases const firstReleaseVersion = issue.firstRelease?.shortVersion; const lastReleaseVersion = issue.lastRelease?.shortVersion; if (firstReleaseVersion || lastReleaseVersion) { if (firstReleaseVersion && lastReleaseVersion) { if (firstReleaseVersion === lastReleaseVersion) { - lines.push(`Release: ${firstReleaseVersion}`); + rows.push(`| **Release** | ${firstReleaseVersion} |`); } else { - lines.push( - `Releases: ${firstReleaseVersion} -> ${lastReleaseVersion}` + rows.push( + `| **Releases** | ${firstReleaseVersion} → ${lastReleaseVersion} |` ); } } else if (lastReleaseVersion) { - lines.push(`Release: ${lastReleaseVersion}`); + rows.push(`| **Release** | ${lastReleaseVersion} |`); } else if (firstReleaseVersion) { - lines.push(`Release: ${firstReleaseVersion}`); + rows.push(`| **Release** | ${firstReleaseVersion} |`); } } - lines.push(""); - // Stats - lines.push(`Events: ${issue.count ?? 0}`); - lines.push(`Users: ${issue.userCount ?? 0}`); + rows.push(`| **Events** | ${issue.count ?? 0} |`); + rows.push(`| **Users** | ${issue.userCount ?? 0} |`); - // First/Last seen with release info if (issue.firstSeen) { - let firstSeenLine = `First seen: ${new Date(issue.firstSeen).toLocaleString()}`; + let firstSeenLine = new Date(issue.firstSeen).toLocaleString(); if (firstReleaseVersion) { firstSeenLine += ` (in ${firstReleaseVersion})`; } - lines.push(firstSeenLine); + rows.push(`| **First seen** | ${firstSeenLine} |`); } if (issue.lastSeen) { - let lastSeenLine = `Last seen: ${new Date(issue.lastSeen).toLocaleString()}`; + let lastSeenLine = new Date(issue.lastSeen).toLocaleString(); if (lastReleaseVersion && lastReleaseVersion !== firstReleaseVersion) { lastSeenLine += ` (in ${lastReleaseVersion})`; } - lines.push(lastSeenLine); + rows.push(`| **Last seen** | ${lastSeenLine} |`); } - lines.push(""); - // Culprit if (issue.culprit) { - lines.push(`Culprit: ${issue.culprit}`); - lines.push(""); + rows.push(`| **Culprit** | \`${issue.culprit}\` |`); } - // Metadata + rows.push(`| **Link** | ${issue.permalink} |`); + + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(...rows); + if (issue.metadata?.value) { - lines.push("Message:"); - lines.push(` ${issue.metadata.value}`); lines.push(""); + lines.push("**Message:**"); + lines.push(""); + lines.push(`> ${issue.metadata.value}`); } if (issue.metadata?.filename) { - lines.push(`File: ${issue.metadata.filename}`); + lines.push(""); + lines.push(`**File:** \`${issue.metadata.filename}\``); } if (issue.metadata?.function) { - lines.push(`Function: ${issue.metadata.function}`); + lines.push(`**Function:** \`${issue.metadata.function}\``); } - // Link - lines.push(""); - lines.push(`Link: ${issue.permalink}`); - - return lines; + return renderMarkdown(lines.join("\n")); } // Stack Trace Formatting /** - * Format a single stack frame + * Format a single stack frame as markdown. */ -function formatStackFrame(frame: StackFrame): string[] { +function formatStackFrameMarkdown(frame: StackFrame): string { const lines: string[] = []; const fn = frame.function || ""; const file = frame.filename || frame.absPath || ""; const line = frame.lineNo ?? "?"; const col = frame.colNo ?? "?"; - const inAppTag = frame.inApp ? " [in-app]" : ""; + const inAppTag = frame.inApp ? " `[in-app]`" : ""; - lines.push(` at ${fn} (${file}:${line}:${col})${inAppTag}`); + lines.push(`\`at ${fn} (${file}:${line}:${col})\`${inAppTag}`); - // Show code context if available if (frame.context && frame.context.length > 0) { + lines.push(""); + lines.push("```"); for (const [lineNo, code] of frame.context) { const isCurrentLine = lineNo === frame.lineNo; const prefix = isCurrentLine ? ">" : " "; - const lineNumStr = String(lineNo).padStart(6); - const codeLine = ` ${prefix} ${lineNumStr} | ${code}`; - lines.push(isCurrentLine ? yellow(codeLine) : muted(codeLine)); + lines.push(`${prefix} ${String(lineNo).padStart(6)} | ${code}`); } - lines.push(""); // blank line after context + lines.push("```"); + lines.push(""); } - return lines; + return lines.join("\n"); } /** - * Format an exception value (type, message, stack trace) + * Format an exception value (type, message, stack trace) as markdown. */ -function formatExceptionValue(exception: ExceptionValue): string[] { +function formatExceptionValueMarkdown(exception: ExceptionValue): string { const lines: string[] = []; - // Exception type and message const type = exception.type || "Error"; const value = exception.value || ""; - lines.push(red(`${type}: ${value}`)); + lines.push(`**\`${type}: ${value}\`**`); - // Mechanism info if (exception.mechanism) { const handled = exception.mechanism.handled ? "handled" : "unhandled"; const mechType = exception.mechanism.type || "unknown"; - lines.push(muted(` mechanism: ${mechType} (${handled})`)); + lines.push(`*mechanism: ${mechType} (${handled})*`); } lines.push(""); - // Stack trace frames (reversed - most recent first, which is last in array) const frames = exception.stacktrace?.frames ?? []; - // Reverse frames so most recent is first (stack traces are usually bottom-up) const reversedFrames = [...frames].reverse(); for (const frame of reversedFrames) { - lines.push(...formatStackFrame(frame)); + lines.push(formatStackFrameMarkdown(frame)); } - return lines; + return lines.join("\n"); } /** - * Format the full stack trace section + * Build the stack trace section as markdown. */ -function formatStackTrace(exceptionEntry: ExceptionEntry): string[] { +function buildStackTraceMarkdown(exceptionEntry: ExceptionEntry): string { const lines: string[] = []; - lines.push(""); - lines.push(muted("─── Stack Trace ───")); + lines.push("### Stack Trace"); lines.push(""); const values = exceptionEntry.data.values ?? []; - // Usually there's one exception, but there can be chained exceptions for (const exception of values) { - lines.push(...formatExceptionValue(exception)); + lines.push(formatExceptionValueMarkdown(exception)); } - return lines; + return lines.join("\n"); } // Breadcrumbs Formatting /** - * Format breadcrumb level with color - */ -function formatBreadcrumbLevel(level: string | undefined): string { - const lvl = (level || "info").padEnd(7); - switch (level) { - case "error": - return red(lvl); - case "warning": - return yellow(lvl); - case "debug": - return muted(lvl); - default: - return muted(lvl); - } -} - -/** - * Format a single breadcrumb + * Build the breadcrumbs section as a markdown table. */ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: breadcrumb formatting logic -function formatBreadcrumb(breadcrumb: Breadcrumb): string { - const timestamp = breadcrumb.timestamp - ? new Date(breadcrumb.timestamp).toLocaleTimeString() - : "??:??:??"; - const level = formatBreadcrumbLevel(breadcrumb.level); - const category = (breadcrumb.category || "default").padEnd(18); - - // Build message from breadcrumb data - let message = breadcrumb.message || ""; - if (!message && breadcrumb.data) { - // Format common breadcrumb types - const data = breadcrumb.data as Record; - if (data.url && data.method) { - // HTTP request breadcrumb - const status = data.status_code ? ` -> ${data.status_code}` : ""; - message = `${data.method} ${data.url}${status}`; - } else if (data.from && data.to) { - // Navigation breadcrumb - message = `${data.from} -> ${data.to}`; - } else if (data.arguments && Array.isArray(data.arguments)) { - // Console breadcrumb - message = String(data.arguments[0] || "").slice(0, 60); - } - } - - // Truncate long messages - if (message.length > 60) { - message = `${message.slice(0, 57)}...`; - } - - return ` ${timestamp} ${level} ${category} ${message}`; -} - -/** - * Format the breadcrumbs section - */ -function formatBreadcrumbs(breadcrumbsEntry: BreadcrumbsEntry): string[] { - const lines: string[] = []; +function buildBreadcrumbsMarkdown(breadcrumbsEntry: BreadcrumbsEntry): string { const breadcrumbs = breadcrumbsEntry.data.values ?? []; - if (breadcrumbs.length === 0) { - return lines; + return ""; } + const lines: string[] = []; + lines.push("### Breadcrumbs"); lines.push(""); - lines.push(muted("─── Breadcrumbs ───")); - lines.push(""); + lines.push("| Time | Level | Category | Message |"); + lines.push("|---|---|---|---|"); - // Show all breadcrumbs, oldest first (they're usually already in order) for (const breadcrumb of breadcrumbs) { - lines.push(formatBreadcrumb(breadcrumb)); + const timestamp = breadcrumb.timestamp + ? new Date(breadcrumb.timestamp).toLocaleTimeString() + : "??:??:??"; + + const level = breadcrumb.level ?? "info"; + + let message = breadcrumb.message ?? ""; + if (!message && breadcrumb.data) { + const data = breadcrumb.data as Record; + if (data.url && data.method) { + const status = data.status_code ? ` → ${data.status_code}` : ""; + message = `${data.method} ${data.url}${status}`; + } else if (data.from && data.to) { + message = `${data.from} → ${data.to}`; + } else if (data.arguments && Array.isArray(data.arguments)) { + message = String(data.arguments[0] || "").slice(0, 60); + } + } + + if (message.length > 80) { + message = `${message.slice(0, 77)}...`; + } + + // Escape pipe characters that would break the markdown table + const safeMessage = message.replace(/\|/g, "\\|"); + const safeCategory = (breadcrumb.category ?? "default").replace( + /\|/g, + "\\|" + ); + + lines.push( + `| ${timestamp} | ${level} | ${safeCategory} | ${safeMessage} |` + ); } - return lines; + return lines.join("\n"); } // Request Formatting /** - * Format the HTTP request section + * Build the HTTP request section as markdown. */ -function formatRequest(requestEntry: RequestEntry): string[] { - const lines: string[] = []; +function buildRequestMarkdown(requestEntry: RequestEntry): string { const data = requestEntry.data; - if (!data.url) { - return lines; + return ""; } + const lines: string[] = []; + lines.push("### Request"); lines.push(""); - lines.push("Request:"); const method = data.method || "GET"; - lines.push(` ${method} ${data.url}`); + lines.push(`\`${method} ${data.url}\``); - // Show User-Agent if available if (data.headers) { for (const [key, value] of data.headers) { if (key.toLowerCase() === "user-agent") { const truncatedUA = - value.length > 70 ? `${value.slice(0, 67)}...` : value; - lines.push(` User-Agent: ${truncatedUA}`); + value.length > 100 ? `${value.slice(0, 97)}...` : value; + lines.push(`**User-Agent:** ${truncatedUA}`); break; } } } - return lines; + return lines.join("\n"); } // Span Tree Formatting @@ -1108,64 +987,53 @@ export function formatSimpleSpanTree( // Environment Context Formatting /** - * Format the environment contexts (browser, OS, device) + * Build environment context section (browser, OS, device) as markdown. */ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: context formatting logic -function formatEnvironmentContexts(event: SentryEvent): string[] { - const lines: string[] = []; +function buildEnvironmentMarkdown(event: SentryEvent): string { const contexts = event.contexts; - if (!contexts) { - return lines; + return ""; } - const parts: string[] = []; + const rows: string[] = []; - // Browser if (contexts.browser) { const name = contexts.browser.name || "Unknown Browser"; const version = contexts.browser.version || ""; - parts.push(`Browser: ${name}${version ? ` ${version}` : ""}`); + rows.push(`| **Browser** | ${name}${version ? ` ${version}` : ""} |`); } - // OS if (contexts.os) { const name = contexts.os.name || "Unknown OS"; const version = contexts.os.version || ""; - parts.push(`OS: ${name}${version ? ` ${version}` : ""}`); + rows.push(`| **OS** | ${name}${version ? ` ${version}` : ""} |`); } - // Device if (contexts.device) { const family = contexts.device.family || contexts.device.model || ""; const brand = contexts.device.brand || ""; if (family || brand) { const device = brand ? `${family} (${brand})` : family; - parts.push(`Device: ${device}`); + rows.push(`| **Device** | ${device} |`); } } - if (parts.length > 0) { - lines.push(""); - lines.push("Environment:"); - for (const part of parts) { - lines.push(` ${part}`); - } + if (rows.length === 0) { + return ""; } - return lines; + return `### Environment\n\n| | |\n|---|---|\n${rows.join("\n")}`; } /** - * Format user information including geo data + * Build user information section as markdown. */ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: user formatting logic -function formatUserInfo(event: SentryEvent): string[] { - const lines: string[] = []; +function buildUserMarkdown(event: SentryEvent): string { const user = event.user; - if (!user) { - return lines; + return ""; } const hasUserData = @@ -1177,29 +1045,27 @@ function formatUserInfo(event: SentryEvent): string[] { user.geo; if (!hasUserData) { - return lines; + return ""; } - lines.push(""); - lines.push("User:"); + const rows: string[] = []; if (user.name) { - lines.push(` Name: ${user.name}`); + rows.push(`| **Name** | ${user.name} |`); } if (user.email) { - lines.push(` Email: ${user.email}`); + rows.push(`| **Email** | ${user.email} |`); } if (user.username) { - lines.push(` Username: ${user.username}`); + rows.push(`| **Username** | ${user.username} |`); } if (user.id) { - lines.push(` ID: ${user.id}`); + rows.push(`| **ID** | ${user.id} |`); } if (user.ip_address) { - lines.push(` IP: ${user.ip_address}`); + rows.push(`| **IP** | ${user.ip_address} |`); } - // Geo information if (user.geo) { const geo = user.geo; const parts: string[] = []; @@ -1213,137 +1079,159 @@ function formatUserInfo(event: SentryEvent): string[] { parts.push(`(${geo.country_code})`); } if (parts.length > 0) { - lines.push(` Location: ${parts.join(", ")}`); + rows.push(`| **Location** | ${parts.join(", ")} |`); } } - return lines; + return `### User\n\n| | |\n|---|---|\n${rows.join("\n")}`; } /** - * Format replay link if available + * Build replay link section as markdown. */ -function formatReplayLink( +function buildReplayMarkdown( event: SentryEvent, issuePermalink?: string -): string[] { - const lines: string[] = []; - - // Find replayId in tags +): string { const replayTag = event.tags?.find((t) => t.key === "replayId"); if (!replayTag?.value) { - return lines; + return ""; } + const lines: string[] = []; + lines.push("### Replay"); lines.push(""); - lines.push(muted("─── Replay ───")); - lines.push(""); - lines.push(` ID: ${replayTag.value}`); + lines.push(`**ID:** \`${replayTag.value}\``); - // Try to construct replay URL from issue permalink if (issuePermalink) { - // Extract base URL from permalink (e.g., https://org.sentry.io/issues/123/) const match = BASE_URL_REGEX.exec(issuePermalink); if (match?.[1]) { - lines.push(` Link: ${match[1]}/replays/${replayTag.value}/`); + lines.push(`**Link:** ${match[1]}/replays/${replayTag.value}/`); } } - return lines; + return lines.join("\n"); } // Event Formatting /** - * Format event details for display. + * Format event details for display as rendered markdown. * * @param event - The Sentry event to format * @param header - Optional header text (defaults to "Latest Event") * @param issuePermalink - Optional issue permalink for constructing replay links - * @returns Array of formatted lines + * @returns Rendered terminal string */ export function formatEventDetails( event: SentryEvent, header = "Latest Event", issuePermalink?: string -): string[] { +): string { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Event formatting requires multiple conditional sections return withSerializeSpan("formatEventDetails", () => { - const lines: string[] = []; + const sections: string[] = []; - // Header - lines.push(""); - lines.push(muted(`─── ${header} (${event.eventID.slice(0, 8)}) ───`)); - lines.push(""); + sections.push(`## ${header} (\`${event.eventID.slice(0, 8)}\`)`); + sections.push(""); - // Basic info - lines.push(`Event ID: ${event.eventID}`); + // Basic info table + const infoRows: string[] = []; + infoRows.push(`| **Event ID** | \`${event.eventID}\` |`); if (event.dateReceived) { - lines.push( - `Received: ${new Date(event.dateReceived).toLocaleString()}` + infoRows.push( + `| **Received** | ${new Date(event.dateReceived).toLocaleString()} |` ); } if (event.location) { - lines.push(`Location: ${event.location}`); + infoRows.push(`| **Location** | \`${event.location}\` |`); } - // Trace context const traceCtx = event.contexts?.trace; if (traceCtx?.trace_id) { - lines.push(`Trace: ${traceCtx.trace_id}`); - } - - // User info (including geo) - lines.push(...formatUserInfo(event)); - - // Environment contexts (browser, OS, device) - lines.push(...formatEnvironmentContexts(event)); - - // HTTP Request - const requestEntry = extractEntry(event, "request"); - if (requestEntry) { - lines.push(...formatRequest(requestEntry)); + infoRows.push(`| **Trace** | \`${traceCtx.trace_id}\` |`); } - // SDK info if (event.sdk?.name || event.sdk?.version) { - lines.push(""); const sdkName = event.sdk.name ?? "unknown"; const sdkVersion = event.sdk.version ?? ""; - lines.push(`SDK: ${sdkName}${sdkVersion ? ` ${sdkVersion}` : ""}`); + infoRows.push( + `| **SDK** | ${sdkName}${sdkVersion ? ` ${sdkVersion}` : ""} |` + ); } - // Release info if (event.release?.shortVersion) { - lines.push(`Release: ${event.release.shortVersion}`); + infoRows.push(`| **Release** | ${event.release.shortVersion} |`); + } + + if (infoRows.length > 0) { + sections.push("| | |"); + sections.push("|---|---|"); + sections.push(...infoRows); + } + + // User section + const userSection = buildUserMarkdown(event); + if (userSection) { + sections.push(""); + sections.push(userSection); + } + + // Environment section + const envSection = buildEnvironmentMarkdown(event); + if (envSection) { + sections.push(""); + sections.push(envSection); + } + + // HTTP Request section + const requestEntry = extractEntry(event, "request"); + if (requestEntry) { + const requestSection = buildRequestMarkdown(requestEntry); + if (requestSection) { + sections.push(""); + sections.push(requestSection); + } } // Stack Trace const exceptionEntry = extractEntry(event, "exception"); if (exceptionEntry) { - lines.push(...formatStackTrace(exceptionEntry)); + sections.push(""); + sections.push(buildStackTraceMarkdown(exceptionEntry)); } // Breadcrumbs const breadcrumbsEntry = extractEntry(event, "breadcrumbs"); if (breadcrumbsEntry) { - lines.push(...formatBreadcrumbs(breadcrumbsEntry)); + const breadcrumbSection = buildBreadcrumbsMarkdown(breadcrumbsEntry); + if (breadcrumbSection) { + sections.push(""); + sections.push(breadcrumbSection); + } } // Replay link - lines.push(...formatReplayLink(event, issuePermalink)); + const replaySection = buildReplayMarkdown(event, issuePermalink); + if (replaySection) { + sections.push(""); + sections.push(replaySection); + } // Tags if (event.tags?.length) { - lines.push(""); - lines.push(muted("─── Tags ───")); - lines.push(""); + sections.push(""); + sections.push("### Tags"); + sections.push(""); + sections.push("| Key | Value |"); + sections.push("|---|---|"); for (const tag of event.tags) { - lines.push(` ${tag.key}: ${tag.value}`); + const safeVal = String(tag.value).replace(/\|/g, "\\|"); + sections.push(`| \`${tag.key}\` | ${safeVal} |`); } } - return lines; + return renderMarkdown(sections.join("\n")); }); } @@ -1367,35 +1255,39 @@ export function calculateOrgSlugWidth(orgs: SentryOrganization[]): number { } /** - * Format detailed organization information. + * Format detailed organization information as rendered markdown. * * @param org - The Sentry organization to format - * @returns Array of formatted lines + * @returns Rendered terminal string */ -export function formatOrgDetails(org: SentryOrganization): string[] { +export function formatOrgDetails(org: SentryOrganization): string { const lines: string[] = []; - // Header - const [header, separator] = formatDetailsHeader(org.slug, org.name); - lines.push(header, separator, ""); + lines.push(`## ${org.slug}: ${org.name || "(unnamed)"}`); + lines.push(""); - // Basic info - lines.push(`Slug: ${org.slug || "(none)"}`); - lines.push(`Name: ${org.name || "(unnamed)"}`); - lines.push(`ID: ${org.id}`); + const rows: string[] = []; + rows.push(`| **Slug** | \`${org.slug || "(none)"}\` |`); + rows.push(`| **Name** | ${org.name || "(unnamed)"} |`); + rows.push(`| **ID** | ${org.id} |`); if (org.dateCreated) { - lines.push(`Created: ${new Date(org.dateCreated).toLocaleString()}`); + rows.push( + `| **Created** | ${new Date(org.dateCreated).toLocaleString()} |` + ); } - lines.push(""); + rows.push(`| **2FA** | ${org.require2FA ? "Required" : "Not required"} |`); + rows.push(`| **Early Adopter** | ${org.isEarlyAdopter ? "Yes" : "No"} |`); - // Settings - lines.push(`2FA: ${org.require2FA ? "Required" : "Not required"}`); - lines.push(`Early Adopter: ${org.isEarlyAdopter ? "Yes" : "No"}`); + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(...rows); - // Features - lines.push(...formatFeaturesList(org.features)); + const featuresSection = formatFeaturesMarkdown(org.features); + if (featuresSection) { + lines.push(featuresSection); + } - return lines; + return renderMarkdown(lines.join("\n")); } // Project Formatting @@ -1437,61 +1329,61 @@ export function calculateProjectColumnWidths( } /** - * Format detailed project information. + * Format detailed project information as rendered markdown. * * @param project - The Sentry project to format * @param dsn - Optional DSN string to display - * @returns Array of formatted lines + * @returns Rendered terminal string */ export function formatProjectDetails( project: SentryProject, dsn?: string | null -): string[] { +): string { const lines: string[] = []; - // Header - const [header, separator] = formatDetailsHeader(project.slug, project.name); - lines.push(header, separator, ""); - - // Basic info - lines.push(`Slug: ${project.slug || "(none)"}`); - lines.push(`Name: ${project.name || "(unnamed)"}`); - lines.push(`ID: ${project.id}`); - lines.push(`Platform: ${project.platform || "Not set"}`); - lines.push(`DSN: ${dsn || "No DSN available"}`); - lines.push(`Status: ${project.status}`); + lines.push(`## ${project.slug}: ${project.name || "(unnamed)"}`); + lines.push(""); + + const rows: string[] = []; + rows.push(`| **Slug** | \`${project.slug || "(none)"}\` |`); + rows.push(`| **Name** | ${project.name || "(unnamed)"} |`); + rows.push(`| **ID** | ${project.id} |`); + rows.push(`| **Platform** | ${project.platform || "Not set"} |`); + rows.push(`| **DSN** | \`${dsn || "No DSN available"}\` |`); + rows.push(`| **Status** | ${project.status} |`); if (project.dateCreated) { - lines.push(`Created: ${new Date(project.dateCreated).toLocaleString()}`); + rows.push( + `| **Created** | ${new Date(project.dateCreated).toLocaleString()} |` + ); } - - // Organization context if (project.organization) { - lines.push(""); - lines.push( - `Organization: ${project.organization.name} (${project.organization.slug})` + rows.push( + `| **Organization** | ${project.organization.name} (\`${project.organization.slug}\`) |` ); } - - // Activity info - lines.push(""); if (project.firstEvent) { - lines.push(`First Event: ${new Date(project.firstEvent).toLocaleString()}`); + rows.push( + `| **First Event** | ${new Date(project.firstEvent).toLocaleString()} |` + ); } else { - lines.push("First Event: No events yet"); + rows.push("| **First Event** | No events yet |"); } - // Capabilities - lines.push(""); - lines.push("Capabilities:"); - lines.push(` Sessions: ${project.hasSessions ? "Yes" : "No"}`); - lines.push(` Replays: ${project.hasReplays ? "Yes" : "No"}`); - lines.push(` Profiles: ${project.hasProfiles ? "Yes" : "No"}`); - lines.push(` Monitors: ${project.hasMonitors ? "Yes" : "No"}`); + rows.push(`| **Sessions** | ${project.hasSessions ? "Yes" : "No"} |`); + rows.push(`| **Replays** | ${project.hasReplays ? "Yes" : "No"} |`); + rows.push(`| **Profiles** | ${project.hasProfiles ? "Yes" : "No"} |`); + rows.push(`| **Monitors** | ${project.hasMonitors ? "Yes" : "No"} |`); - // Features - lines.push(...formatFeaturesList(project.features)); + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(...rows); + + const featuresSection = formatFeaturesMarkdown(project.features); + if (featuresSection) { + lines.push(featuresSection); + } - return lines; + return renderMarkdown(lines.join("\n")); } // User Identity Formatting diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index 9dadb530..104d7a72 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -9,6 +9,7 @@ export * from "./colors.js"; export * from "./human.js"; export * from "./json.js"; export * from "./log.js"; +export * from "./markdown.js"; export * from "./output.js"; export * from "./seer.js"; export * from "./table.js"; diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 7473cc5b..1d762964 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -7,7 +7,7 @@ import type { DetailedSentryLog, SentryLog } from "../../types/index.js"; import { buildTraceUrl } from "../sentry-urls.js"; import { cyan, muted, red, yellow } from "./colors.js"; -import { divider } from "./human.js"; +import { renderMarkdown } from "./markdown.js"; /** Color functions for log severity levels */ const SEVERITY_COLORS: Record string> = { @@ -71,13 +71,38 @@ export function formatLogRow(log: SentryLog): string { } /** - * Format column header for logs list. + * Format column header for logs list (used in streaming/follow mode). * - * @returns Header line with column titles and divider + * @returns Header line with column titles and separator */ export function formatLogsHeader(): string { const header = muted("TIMESTAMP LEVEL MESSAGE"); - return `${header}\n${divider(80)}\n`; + return `${header}\n${muted("─".repeat(80))}\n`; +} + +/** + * Build a markdown table for a list of log entries. + * + * Pre-rendered ANSI codes in cell values (e.g. colored severity) are preserved. + * + * @param logs - Log entries to display + * @returns Rendered terminal string with Unicode-bordered table + */ +export function formatLogTable(logs: SentryLog[]): string { + const header = "| Timestamp | Level | Message |"; + const separator = "| --- | --- | --- |"; + const rows = logs + .map((log) => { + const timestamp = formatTimestamp(log.timestamp); + // Pre-render ANSI severity color — survives the cli-table3 pipeline + const severity = formatSeverity(log.severity).trim(); + const message = (log.message ?? "").replace(/\|/g, "\\|"); + const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; + return `| ${timestamp} | ${severity} | ${message}${trace} |`; + }) + .join("\n"); + + return renderMarkdown(`${header}\n${separator}\n${rows}`); } /** @@ -92,88 +117,93 @@ function formatSeverityLabel(severity: string | null | undefined): string { return colorFn(level.toUpperCase()); } -/** Minimum width for header separator line */ -const MIN_HEADER_WIDTH = 20; - /** - * Format detailed log entry for display. + * Format detailed log entry for display as rendered markdown. * Shows all available fields in a structured format. * * @param log - The detailed log entry to format * @param orgSlug - Organization slug for building trace URLs - * @returns Array of formatted lines + * @returns Rendered terminal string */ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: log detail formatting requires multiple conditional sections export function formatLogDetails( log: DetailedSentryLog, orgSlug: string -): string[] { - const lines: string[] = []; +): string { const logId = log["sentry.item_id"]; + const lines: string[] = []; - // Header - const headerText = `Log ${logId.slice(0, 12)}...`; - const separatorWidth = Math.max( - MIN_HEADER_WIDTH, - Math.min(80, headerText.length) - ); - lines.push(headerText); - lines.push(muted("═".repeat(separatorWidth))); + lines.push(`## Log \`${logId.slice(0, 12)}...\``); lines.push(""); - // Core fields - lines.push(`ID: ${logId}`); - lines.push(`Timestamp: ${formatTimestamp(log.timestamp)}`); - lines.push(`Severity: ${formatSeverityLabel(log.severity)}`); + // Core fields table + const rows: string[] = []; + rows.push(`| **ID** | \`${logId}\` |`); + rows.push(`| **Timestamp** | ${formatTimestamp(log.timestamp)} |`); + rows.push(`| **Severity** | ${formatSeverityLabel(log.severity)} |`); + + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(...rows); - // Message (may be multi-line or long) if (log.message) { lines.push(""); - lines.push("Message:"); - lines.push(` ${log.message}`); + lines.push("**Message:**"); + lines.push(""); + lines.push(`> ${log.message.replace(/\n/g, "\n> ")}`); } - lines.push(""); // Context section if (log.project || log.environment || log.release) { - lines.push(muted("─── Context ───")); lines.push(""); + lines.push("### Context"); + lines.push(""); + const ctxRows: string[] = []; if (log.project) { - lines.push(`Project: ${log.project}`); + ctxRows.push(`| **Project** | ${log.project} |`); } if (log.environment) { - lines.push(`Environment: ${log.environment}`); + ctxRows.push(`| **Environment** | ${log.environment} |`); } if (log.release) { - lines.push(`Release: ${log.release}`); + ctxRows.push(`| **Release** | ${log.release} |`); } - lines.push(""); + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(...ctxRows); } // SDK section const sdkName = log["sdk.name"]; const sdkVersion = log["sdk.version"]; if (sdkName || sdkVersion) { - lines.push(muted("─── SDK ───")); lines.push(""); + lines.push("### SDK"); + lines.push(""); + // Wrap in backticks to prevent markdown from interpreting underscores/dashes const sdkInfo = sdkName && sdkVersion - ? `${sdkName} ${sdkVersion}` - : sdkName || sdkVersion; - lines.push(`SDK: ${sdkInfo}`); - lines.push(""); + ? `\`${sdkName} ${sdkVersion}\`` + : `\`${sdkName ?? sdkVersion}\``; + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(`| **SDK** | ${sdkInfo} |`); } // Trace section if (log.trace) { - lines.push(muted("─── Trace ───")); lines.push(""); - lines.push(`Trace ID: ${log.trace}`); + lines.push("### Trace"); + lines.push(""); + const traceRows: string[] = []; + traceRows.push(`| **Trace ID** | \`${log.trace}\` |`); if (log.span_id) { - lines.push(`Span ID: ${log.span_id}`); + traceRows.push(`| **Span ID** | \`${log.span_id}\` |`); } - lines.push(`Link: ${buildTraceUrl(orgSlug, log.trace)}`); - lines.push(""); + traceRows.push(`| **Link** | ${buildTraceUrl(orgSlug, log.trace)} |`); + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(...traceRows); } // Source location section (OTel code attributes) @@ -181,38 +211,46 @@ export function formatLogDetails( const codeFilePath = log["code.file.path"]; const codeLineNumber = log["code.line.number"]; if (codeFunction || codeFilePath) { - lines.push(muted("─── Source Location ───")); lines.push(""); + lines.push("### Source Location"); + lines.push(""); + const srcRows: string[] = []; if (codeFunction) { - lines.push(`Function: ${codeFunction}`); + srcRows.push(`| **Function** | \`${codeFunction}\` |`); } if (codeFilePath) { const location = codeLineNumber ? `${codeFilePath}:${codeLineNumber}` : codeFilePath; - lines.push(`File: ${location}`); + srcRows.push(`| **File** | \`${location}\` |`); } - lines.push(""); + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(...srcRows); } - // OpenTelemetry section (if any OTel fields are present) + // OpenTelemetry section const otelKind = log["sentry.otel.kind"]; const otelStatus = log["sentry.otel.status_code"]; const otelScope = log["sentry.otel.instrumentation_scope.name"]; if (otelKind || otelStatus || otelScope) { - lines.push(muted("─── OpenTelemetry ───")); lines.push(""); + lines.push("### OpenTelemetry"); + lines.push(""); + const otelRows: string[] = []; if (otelKind) { - lines.push(`Kind: ${otelKind}`); + otelRows.push(`| **Kind** | ${otelKind} |`); } if (otelStatus) { - lines.push(`Status: ${otelStatus}`); + otelRows.push(`| **Status** | ${otelStatus} |`); } if (otelScope) { - lines.push(`Scope: ${otelScope}`); + otelRows.push(`| **Scope** | ${otelScope} |`); } - lines.push(""); + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(...otelRows); } - return lines; + return renderMarkdown(lines.join("\n")); } diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts new file mode 100644 index 00000000..8215c802 --- /dev/null +++ b/src/lib/formatters/markdown.ts @@ -0,0 +1,90 @@ +/** + * Markdown-to-Terminal Renderer + * + * Central utility for rendering markdown content as styled terminal output + * using `marked` + `marked-terminal`. Provides a single `renderMarkdown()` + * function that all formatters can use for rich text output. + * + * Pre-rendered ANSI escape codes embedded in markdown source (e.g. inside + * table cells) survive the pipeline — `cli-table3` computes column widths + * via `string-width`, which correctly treats ANSI codes as zero-width. + */ + +import chalk from "chalk"; +import { type MarkedExtension, marked } from "marked"; +import { markedTerminal as _markedTerminal } from "marked-terminal"; + +// @types/marked-terminal@6 describes the legacy class-based API; the package's +// actual markedTerminal() returns a {renderer, useNewRenderer} MarkedExtension +// object compatible with marked@15's marked.use(). +const markedTerminal = _markedTerminal as unknown as ( + options?: Parameters[0] +) => MarkedExtension; + +/** Sentinel-inspired color palette (mirrors colors.ts) */ +const COLORS = { + red: "#fe4144", + green: "#83da90", + yellow: "#FDB81B", + blue: "#226DFC", + cyan: "#79B8FF", + muted: "#898294", +} as const; + +marked.use( + markedTerminal({ + // Map markdown elements to our Sentinel palette + code: chalk.hex(COLORS.yellow), + blockquote: chalk.hex(COLORS.muted).italic, + heading: chalk.hex(COLORS.cyan).bold, + firstHeading: chalk.hex(COLORS.cyan).bold, + hr: chalk.hex(COLORS.muted), + listitem: chalk.reset, + table: chalk.reset, + paragraph: chalk.reset, + strong: chalk.bold, + em: chalk.italic, + codespan: chalk.hex(COLORS.yellow), + del: chalk.dim.gray.strikethrough, + link: chalk.hex(COLORS.blue), + href: chalk.hex(COLORS.blue).underline, + + // No "§ " section prefix before headings + showSectionPrefix: false, + + // Standard 80-column width; no reflow (let terminal wrap naturally) + width: 80, + reflowText: false, + + // Unescape HTML entities produced by the markdown parser + unescape: true, + + // Render emoji shortcodes (e.g. :tada:) + emoji: true, + + // Two-space tabs for code blocks + tab: 2, + }) +); + +/** + * Render a markdown string as styled terminal output. + * + * Supports the full CommonMark spec: + * - Headings, bold, italic, strikethrough + * - Fenced code blocks with syntax highlighting (via cli-highlight) + * - Inline code spans + * - Tables rendered with Unicode box-drawing (via cli-table3) + * - Ordered and unordered lists + * - Blockquotes + * - Links and images + * - Horizontal rules + * + * Pre-rendered ANSI escape codes in the input are preserved. + * + * @param md - Markdown source text + * @returns Styled terminal string with trailing whitespace trimmed + */ +export function renderMarkdown(md: string): string { + return (marked.parse(md) as string).trimEnd(); +} diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index 3b293362..44f9052f 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -12,8 +12,8 @@ import { writeJson } from "./json.js"; type WriteOutputOptions = { /** Output JSON format instead of human-readable */ json: boolean; - /** Function to format data as human-readable lines */ - formatHuman: (data: T) => string[]; + /** Function to format data as a rendered string or string array */ + formatHuman: (data: T) => string | string[]; /** Optional source description if data was auto-detected */ detectedFrom?: string; }; @@ -36,8 +36,9 @@ export function writeOutput( return; } - const lines = options.formatHuman(data); - stdout.write(`${lines.join("\n")}\n`); + const output = options.formatHuman(data); + const text = Array.isArray(output) ? output.join("\n") : output; + stdout.write(`${text}\n`); if (options.detectedFrom) { stdout.write(`\nDetected from ${options.detectedFrom}\n`); diff --git a/src/lib/formatters/seer.ts b/src/lib/formatters/seer.ts index 5265f1a7..9762af39 100644 --- a/src/lib/formatters/seer.ts +++ b/src/lib/formatters/seer.ts @@ -1,19 +1,18 @@ /** * Seer Output Formatters * - * Formatting utilities for Seer Autofix command output. + * Formatting utilities for Seer Autofix command output. All human-readable + * output is built as markdown and rendered via renderMarkdown(). */ -import chalk from "chalk"; import type { AutofixState, RootCause, SolutionArtifact, } from "../../types/seer.js"; import { SeerError } from "../errors.js"; -import { cyan, green, muted, yellow } from "./colors.js"; - -const bold = (text: string): string => chalk.bold(text); +import { cyan } from "./colors.js"; +import { renderMarkdown } from "./markdown.js"; // Spinner Frames @@ -99,96 +98,66 @@ export function getProgressMessage(state: AutofixState): string { // Root Cause Formatting /** - * Format a single reproduction step. - */ -function formatReproductionStep( - step: { title: string; code_snippet_and_analysis: string }, - index: number -): string[] { - const lines: string[] = []; - lines.push(` ${index + 1}. ${step.title}`); - - // Indent the analysis - const analysisLines = step.code_snippet_and_analysis - .split("\n") - .map((line) => ` ${line}`); - lines.push(...analysisLines); - - return lines; -} - -/** - * Format a single root cause for display. + * Build a markdown document for a single root cause. * * @param cause - Root cause to format * @param index - Index for display (used as cause ID) - * @returns Array of formatted lines + * @returns Markdown string for this cause */ -export function formatRootCause(cause: RootCause, index: number): string[] { +function buildRootCauseMarkdown(cause: RootCause, index: number): string { const lines: string[] = []; - // Cause header - lines.push(`${yellow(`Cause #${index}`)}: ${cause.description}`); + lines.push(`### Cause #${index}: ${cause.description}`); + lines.push(""); - // Relevant repositories if (cause.relevant_repos && cause.relevant_repos.length > 0) { - lines.push(` ${muted("Repository:")} ${cause.relevant_repos.join(", ")}`); + lines.push(`**Repository:** ${cause.relevant_repos.join(", ")}`); + lines.push(""); } - // Reproduction steps if ( cause.root_cause_reproduction && cause.root_cause_reproduction.length > 0 ) { + lines.push("**Reproduction steps:**"); lines.push(""); - lines.push(` ${muted("Reproduction:")}`); - for (let i = 0; i < cause.root_cause_reproduction.length; i++) { - const step = cause.root_cause_reproduction[i]; - if (step) { - lines.push(...formatReproductionStep(step, i)); - } + for (const step of cause.root_cause_reproduction) { + lines.push(`**${step.title}**`); + lines.push(""); + // code_snippet_and_analysis may itself contain markdown (code fences, + // inline code, etc.) — pass it through as-is so marked renders it. + lines.push(step.code_snippet_and_analysis); + lines.push(""); } } - return lines; + return lines.join("\n"); } /** - * Format the root cause analysis header. - * - * @returns Array of formatted header lines - */ -export function formatRootCauseHeader(): string[] { - return ["", green("Root Cause Analysis Complete"), muted("═".repeat(30)), ""]; -} - -/** - * Format all root causes for display. + * Format all root causes as rendered terminal output. * * @param causes - Array of root causes - * @returns Array of formatted lines + * @returns Rendered terminal string */ -export function formatRootCauseList(causes: RootCause[]): string[] { +export function formatRootCauseList(causes: RootCause[]): string { const lines: string[] = []; - lines.push(...formatRootCauseHeader()); + lines.push("## Root Cause Analysis Complete"); + lines.push(""); if (causes.length === 0) { - lines.push(muted("No root causes identified.")); - return lines; - } - - for (let i = 0; i < causes.length; i++) { - const cause = causes[i]; - if (cause) { - if (i > 0) { - lines.push(""); + lines.push("*No root causes identified.*"); + } else { + for (let i = 0; i < causes.length; i++) { + const cause = causes[i]; + if (cause) { + lines.push(buildRootCauseMarkdown(cause, i)); } - lines.push(...formatRootCause(cause, i)); } } - return lines; + return renderMarkdown(lines.join("\n")); } // Error Messages @@ -265,53 +234,46 @@ export function formatAutofixError(status: number, detail?: string): string { // Solution Formatting /** - * Format a solution artifact for human-readable display. + * Format a solution artifact as rendered terminal output. + * + * Renders a markdown document: * - * Output format: - * Solution - * ════════════════════════════════════════════════════════════ + * ## Solution * - * Summary: - * {one_line_summary} + * **Summary:** {one_line_summary} * - * Steps to implement: - * 1. {title} - * {description} + * ### Steps to implement * - * 2. {title} - * {description} - * ... + * 1. **{title}** + * + * {description} * * @param solution - Solution artifact from autofix - * @returns Array of formatted lines + * @returns Rendered terminal string */ -export function formatSolution(solution: SolutionArtifact): string[] { +export function formatSolution(solution: SolutionArtifact): string { const lines: string[] = []; - // Header - lines.push(""); - lines.push(bold("Solution")); - lines.push("═".repeat(60)); + lines.push("## Solution"); lines.push(""); - // Summary - lines.push(yellow("Summary:")); - lines.push(` ${solution.data.one_line_summary}`); + lines.push(`**Summary:** ${solution.data.one_line_summary}`); lines.push(""); - // Steps to implement if (solution.data.steps.length > 0) { - lines.push(cyan("Steps to implement:")); + lines.push("### Steps to implement"); lines.push(""); for (let i = 0; i < solution.data.steps.length; i++) { const step = solution.data.steps[i]; if (step) { - lines.push(` ${i + 1}. ${bold(step.title)}`); - lines.push(` ${muted(step.description)}`); + lines.push(`${i + 1}. **${step.title}**`); + lines.push(""); + // step.description may contain markdown — pass it through as-is + lines.push(` ${step.description.split("\n").join("\n ")}`); lines.push(""); } } } - return lines; + return renderMarkdown(lines.join("\n")); } diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index 4bb07a85..6dca7482 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -1,11 +1,14 @@ /** * Generic column-based table renderer. * - * Replaces the duplicated calculateColumnWidths / writeHeader / writeRows - * pattern used across team, repo, and project list commands. + * Generates markdown tables and renders them through `renderMarkdown()` so + * all list commands get consistent Unicode-bordered tables via cli-table3. + * Pre-rendered ANSI escape codes in cell values are preserved — cli-table3 + * uses string-width which correctly treats them as zero-width. */ import type { Writer } from "../../types/index.js"; +import { renderMarkdown } from "./markdown.js"; /** * Describes a single column in a table. @@ -24,10 +27,32 @@ export type Column = { }; /** - * Render items as a formatted table with auto-sized columns. + * Build a markdown table string from items and column definitions. * - * Column widths are computed as `max(header.length, minWidth, longestValue)`. - * Columns are separated by two spaces. No trailing separator after the last column. + * ANSI escape codes in cell values survive the markdown pipeline — + * cli-table3 uses `string-width` for column width calculation. + * + * @param items - Row data + * @param columns - Column definitions + * @returns Markdown table string + */ +export function buildMarkdownTable( + items: T[], + columns: Column[] +): string { + const header = `| ${columns.map((c) => c.header).join(" | ")} |`; + const separator = `| ${columns.map((c) => (c.align === "right" ? "---:" : "---")).join(" | ")} |`; + const rows = items + .map((item) => `| ${columns.map((c) => c.value(item)).join(" | ")} |`) + .join("\n"); + return `${header}\n${separator}\n${rows}`; +} + +/** + * Render items as a formatted table with Unicode borders. + * + * Column widths are auto-sized by cli-table3. Columns are defined via the + * `columns` array; ANSI-colored cell values are preserved. * * @param stdout - Output writer * @param items - Row data @@ -38,37 +63,5 @@ export function writeTable( items: T[], columns: Column[] ): void { - // Pre-compute widths - const widths = columns.map((col) => { - const headerLen = col.header.length; - const minLen = col.minWidth ?? 0; - const maxValue = items.reduce( - (max, item) => Math.max(max, col.value(item).length), - 0 - ); - return Math.max(headerLen, minLen, maxValue); - }); - - // Header row - const headerCells = columns.map((col, i) => - pad(col.header, widths[i] as number, col.align) - ); - stdout.write(`${headerCells.join(" ")}\n`); - - // Data rows - for (const item of items) { - const cells = columns.map((col, i) => - pad(col.value(item), widths[i] as number, col.align) - ); - stdout.write(`${cells.join(" ")}\n`); - } -} - -/** Pad a string to width with the given alignment. */ -function pad( - value: string, - width: number, - align: "left" | "right" = "left" -): string { - return align === "right" ? value.padStart(width) : value.padEnd(width); + stdout.write(`${renderMarkdown(buildMarkdownTable(items, columns))}\n`); } diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 658e2d51..99f7261c 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -6,7 +6,8 @@ import type { TraceSpan, TransactionListItem } from "../../types/index.js"; import { muted } from "./colors.js"; -import { divider, formatRelativeTime } from "./human.js"; +import { formatRelativeTime } from "./human.js"; +import { renderMarkdown } from "./markdown.js"; /** * Format a duration in milliseconds to a human-readable string. @@ -41,15 +42,15 @@ export function formatTraceDuration(ms: number): string { } /** - * Format column header for traces list. + * Format column header for traces list (used before per-row output). * - * @returns Header line with column titles and divider + * @returns Header line with column titles and separator */ export function formatTracesHeader(): string { const header = muted( "TRACE ID TRANSACTION DURATION WHEN" ); - return `${header}\n${divider(96)}\n`; + return `${header}\n${muted("─".repeat(96))}\n`; } /** Maximum transaction name length before truncation */ @@ -80,6 +81,32 @@ export function formatTraceRow(item: TransactionListItem): string { return `${traceId} ${transaction} ${duration} ${when}\n`; } +/** + * Build a markdown table for a list of trace transactions. + * + * Pre-rendered ANSI codes in cell values are preserved through the pipeline. + * + * @param items - Transaction list items from the API + * @returns Rendered terminal string with Unicode-bordered table + */ +export function formatTraceTable(items: TransactionListItem[]): string { + const header = "| Trace ID | Transaction | Duration | When |"; + const separator = "| --- | --- | ---: | --- |"; + const rows = items + .map((item) => { + const traceId = item.trace; + const transaction = item.transaction || "unknown"; + const duration = formatTraceDuration(item["transaction.duration"]); + const when = formatRelativeTime(item.timestamp).trim(); + // Escape pipe characters in cell values to avoid breaking the table + const safeTransaction = transaction.replace(/\|/g, "\\|"); + return `| \`${traceId}\` | ${safeTransaction} | ${duration} | ${when} |`; + }) + .join("\n"); + + return renderMarkdown(`${header}\n${separator}\n${rows}`); +} + /** Trace summary computed from a span tree */ export type TraceSummary = { /** The 32-character trace ID */ @@ -200,43 +227,31 @@ export function computeTraceSummary( }; } -/** Minimum width for header separator line */ -const MIN_HEADER_WIDTH = 20; - /** - * Format trace summary for human-readable display. + * Format trace summary for human-readable display as rendered markdown. * Shows metadata including root transaction, duration, span count, and projects. * * @param summary - Computed trace summary - * @returns Array of formatted lines + * @returns Rendered terminal string */ -export function formatTraceSummary(summary: TraceSummary): string[] { - const lines: string[] = []; - - // Header - const headerText = `Trace ${summary.traceId}`; - const separatorWidth = Math.max( - MIN_HEADER_WIDTH, - Math.min(80, headerText.length) - ); - lines.push(headerText); - lines.push(muted("═".repeat(separatorWidth))); - lines.push(""); +export function formatTraceSummary(summary: TraceSummary): string { + const rows: string[] = []; if (summary.rootTransaction) { - const opPrefix = summary.rootOp ? `[${summary.rootOp}] ` : ""; - lines.push(`Root: ${opPrefix}${summary.rootTransaction}`); + const opPrefix = summary.rootOp ? `[\`${summary.rootOp}\`] ` : ""; + rows.push(`| **Root** | ${opPrefix}${summary.rootTransaction} |`); } - lines.push(`Duration: ${formatTraceDuration(summary.duration)}`); - lines.push(`Span Count: ${summary.spanCount}`); + rows.push(`| **Duration** | ${formatTraceDuration(summary.duration)} |`); + rows.push(`| **Spans** | ${summary.spanCount} |`); if (summary.projects.length > 0) { - lines.push(`Projects: ${summary.projects.join(", ")}`); + rows.push(`| **Projects** | ${summary.projects.join(", ")} |`); } if (Number.isFinite(summary.startTimestamp) && summary.startTimestamp > 0) { const date = new Date(summary.startTimestamp * 1000); - lines.push(`Started: ${date.toLocaleString("sv-SE")}`); + rows.push(`| **Started** | ${date.toLocaleString("sv-SE")} |`); } - lines.push(""); - return lines; + const table = `| | |\n|---|---|\n${rows.join("\n")}`; + const md = `## Trace \`${summary.traceId}\`\n\n${table}\n`; + return renderMarkdown(md); } diff --git a/test/commands/project/view.func.test.ts b/test/commands/project/view.func.test.ts index cb7a3244..90d70fc6 100644 --- a/test/commands/project/view.func.test.ts +++ b/test/commands/project/view.func.test.ts @@ -109,7 +109,7 @@ describe("viewCommand.func", () => { const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("test-project"); - expect(output).toContain("Slug:"); + expect(output).toContain("Slug"); }); test("explicit org/project with --web opens browser", async () => { diff --git a/test/lib/formatters/human.details.test.ts b/test/lib/formatters/human.details.test.ts index b5a752a3..4e0aba64 100644 --- a/test/lib/formatters/human.details.test.ts +++ b/test/lib/formatters/human.details.test.ts @@ -128,42 +128,44 @@ describe("formatOrgDetails", () => { isEarlyAdopter: true, }); - const lines = formatOrgDetails(org).map(stripAnsi); + const result = stripAnsi(formatOrgDetails(org)); + const lines = result.split("\n"); - expect(lines[0]).toBe("acme: Acme Corp"); - expect(lines.some((l) => l.includes("Slug: acme"))).toBe(true); - expect(lines.some((l) => l.includes("Name: Acme Corp"))).toBe(true); - expect(lines.some((l) => l.includes("ID: 123"))).toBe(true); - expect(lines.some((l) => l.includes("2FA: Required"))).toBe(true); - expect(lines.some((l) => l.includes("Early Adopter: Yes"))).toBe(true); + // Header contains slug and name + expect(lines[0]).toContain("acme"); + expect(lines[0]).toContain("Acme Corp"); + + // Table rows contain the right values + expect(result).toContain("acme"); + expect(result).toContain("Acme Corp"); + expect(result).toContain("123"); + expect(result).toContain("Required"); + expect(result).toContain("Yes"); }); test("formats organization without 2FA", () => { const org = createMockOrg({ require2FA: false, isEarlyAdopter: false }); - const lines = formatOrgDetails(org).map(stripAnsi); + const result = stripAnsi(formatOrgDetails(org)); - expect(lines.some((l) => l.includes("2FA: Not required"))).toBe( - true - ); - expect(lines.some((l) => l.includes("Early Adopter: No"))).toBe(true); + expect(result).toContain("Not required"); + expect(result).toContain("No"); }); test("includes features when present", () => { const org = createMockOrg({ features: ["feature-a", "feature-b", "feature-c"], }); - const lines = formatOrgDetails(org).map(stripAnsi); + const result = stripAnsi(formatOrgDetails(org)); - expect(lines.some((l) => l.includes("Features (3)"))).toBe(true); - expect(lines.some((l) => l.includes("feature-a"))).toBe(true); + expect(result).toContain("Features"); + expect(result).toContain("feature-a"); }); test("handles missing dateCreated", () => { const org = createMockOrg({ dateCreated: undefined }); - const lines = formatOrgDetails(org).map(stripAnsi); - - // Should not throw and should not include Created line - expect(lines.some((l) => l.startsWith("Created:"))).toBe(false); + // Should not throw + const result = stripAnsi(formatOrgDetails(org)); + expect(result).not.toContain("Created"); }); }); @@ -267,35 +269,34 @@ describe("formatProjectDetails", () => { status: "active", }); - const lines = formatProjectDetails(project).map(stripAnsi); + const result = stripAnsi(formatProjectDetails(project)); + const lines = result.split("\n"); + + // Header contains slug and name + expect(lines[0]).toContain("frontend"); + expect(lines[0]).toContain("Frontend App"); - expect(lines[0]).toBe("frontend: Frontend App"); - expect(lines.some((l) => l.includes("Slug: frontend"))).toBe(true); - expect(lines.some((l) => l.includes("Name: Frontend App"))).toBe( - true - ); - expect(lines.some((l) => l.includes("ID: 456"))).toBe(true); - expect(lines.some((l) => l.includes("Platform: javascript"))).toBe(true); - expect(lines.some((l) => l.includes("Status: active"))).toBe(true); + // Table rows contain the right values + expect(result).toContain("frontend"); + expect(result).toContain("Frontend App"); + expect(result).toContain("456"); + expect(result).toContain("javascript"); + expect(result).toContain("active"); }); test("includes DSN when provided", () => { const project = createMockProject(); const dsn = "https://abc123@sentry.io/456"; - const lines = formatProjectDetails(project, dsn).map(stripAnsi); - - expect(lines.some((l) => l.includes(`DSN: ${dsn}`))).toBe(true); + const result = stripAnsi(formatProjectDetails(project, dsn)); + expect(result).toContain(dsn); }); test("shows 'No DSN available' when DSN is null", () => { const project = createMockProject(); - const lines = formatProjectDetails(project, null).map(stripAnsi); - - expect(lines.some((l) => l.includes("DSN: No DSN available"))).toBe( - true - ); + const result = stripAnsi(formatProjectDetails(project, null)); + expect(result).toContain("No DSN available"); }); test("includes organization context when present", () => { @@ -303,11 +304,9 @@ describe("formatProjectDetails", () => { organization: { slug: "acme", name: "Acme Corp" }, }); - const lines = formatProjectDetails(project).map(stripAnsi); - - expect( - lines.some((l) => l.includes("Organization: Acme Corp (acme)")) - ).toBe(true); + const result = stripAnsi(formatProjectDetails(project)); + expect(result).toContain("Acme Corp"); + expect(result).toContain("acme"); }); test("includes capability flags", () => { @@ -318,34 +317,26 @@ describe("formatProjectDetails", () => { hasMonitors: true, }); - const lines = formatProjectDetails(project).map(stripAnsi); - - expect(lines.some((l) => l.includes("Sessions: Yes"))).toBe(true); - expect(lines.some((l) => l.includes("Replays: Yes"))).toBe(true); - expect(lines.some((l) => l.includes("Profiles: No"))).toBe(true); - expect(lines.some((l) => l.includes("Monitors: Yes"))).toBe(true); + const result = stripAnsi(formatProjectDetails(project)); + expect(result).toContain("Sessions"); + expect(result).toContain("Replays"); + expect(result).toContain("Profiles"); + expect(result).toContain("Monitors"); }); test("handles missing firstEvent", () => { const project = createMockProject({ firstEvent: undefined }); - const lines = formatProjectDetails(project).map(stripAnsi); - - expect(lines.some((l) => l.includes("First Event: No events yet"))).toBe( - true - ); + const result = stripAnsi(formatProjectDetails(project)); + expect(result).toContain("No events yet"); }); test("formats firstEvent date when present", () => { const project = createMockProject({ firstEvent: "2024-06-15T10:30:00Z", }); - const lines = formatProjectDetails(project).map(stripAnsi); - - expect(lines.some((l) => l.startsWith("First Event:"))).toBe(true); - // Should contain a formatted date (locale-dependent) - expect(lines.some((l) => l.includes("2024") || l.includes("15"))).toBe( - true - ); + const result = stripAnsi(formatProjectDetails(project)); + // Should contain year (locale-dependent format) + expect(result).toContain("2024"); }); }); @@ -362,13 +353,19 @@ describe("formatIssueDetails", () => { userCount: 25, }); - const lines = formatIssueDetails(issue).map(stripAnsi); + const result = stripAnsi(formatIssueDetails(issue)); + const lines = result.split("\n"); - expect(lines[0]).toBe("PROJ-ABC: Test Error"); - expect(lines.some((l) => l.includes("Status:"))).toBe(true); - expect(lines.some((l) => l.includes("Level: error"))).toBe(true); - expect(lines.some((l) => l.includes("Events: 100"))).toBe(true); - expect(lines.some((l) => l.includes("Users: 25"))).toBe(true); + // Header contains shortId and title + expect(lines[0]).toContain("PROJ-ABC"); + expect(lines[0]).toContain("Test Error"); + + // Table contains key fields + expect(result).toContain("Status"); + expect(result).toContain("Level"); + expect(result).toContain("error"); + expect(result).toContain("100"); + expect(result).toContain("25"); }); test("includes substatus when present", () => { @@ -377,23 +374,21 @@ describe("formatIssueDetails", () => { substatus: "ongoing", }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("(Ongoing)"))).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Ongoing"); }); test("includes priority when present", () => { const issue = createMockIssue({ priority: "high" }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("Priority: High"))).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Priority"); + expect(result).toContain("High"); }); test("shows unhandled indicator", () => { const issue = createMockIssue({ isUnhandled: true }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("(unhandled)"))).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("unhandled"); }); test("includes project info when present", () => { @@ -401,11 +396,9 @@ describe("formatIssueDetails", () => { project: { slug: "frontend", name: "Frontend App" }, }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect( - lines.some((l) => l.includes("Project: Frontend App (frontend)")) - ).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Frontend App"); + expect(result).toContain("frontend"); }); test("formats single release correctly", () => { @@ -414,9 +407,9 @@ describe("formatIssueDetails", () => { lastRelease: { shortVersion: "1.0.0" }, }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l === "Release: 1.0.0")).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("1.0.0"); + expect(result).toContain("Release"); }); test("formats release range when different", () => { @@ -425,11 +418,10 @@ describe("formatIssueDetails", () => { lastRelease: { shortVersion: "2.0.0" }, }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("Releases: 1.0.0 -> 2.0.0"))).toBe( - true - ); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("1.0.0"); + expect(result).toContain("2.0.0"); + expect(result).toContain("→"); }); test("includes assignee name", () => { @@ -437,25 +429,20 @@ describe("formatIssueDetails", () => { assignedTo: { name: "John Doe" }, }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("Assignee: John Doe"))).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("John Doe"); }); test("shows Unassigned when no assignee", () => { const issue = createMockIssue({ assignedTo: undefined }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("Assignee: Unassigned"))).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Unassigned"); }); test("includes culprit when present", () => { const issue = createMockIssue({ culprit: "app.js in handleClick" }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect( - lines.some((l) => l.includes("Culprit: app.js in handleClick")) - ).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("app.js in handleClick"); }); test("includes metadata message when present", () => { @@ -463,12 +450,9 @@ describe("formatIssueDetails", () => { metadata: { value: "Cannot read property 'x' of null" }, }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l === "Message:")).toBe(true); - expect( - lines.some((l) => l.includes("Cannot read property 'x' of null")) - ).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Message"); + expect(result).toContain("Cannot read property"); }); test("includes metadata filename and function", () => { @@ -476,10 +460,9 @@ describe("formatIssueDetails", () => { metadata: { filename: "src/app.js", function: "handleClick" }, }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("File: src/app.js"))).toBe(true); - expect(lines.some((l) => l.includes("Function: handleClick"))).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("src/app.js"); + expect(result).toContain("handleClick"); }); test("includes permalink", () => { @@ -487,11 +470,8 @@ describe("formatIssueDetails", () => { permalink: "https://sentry.io/issues/123", }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect( - lines.some((l) => l.includes("Link: https://sentry.io/issues/123")) - ).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("https://sentry.io/issues/123"); }); test("handles missing optional fields gracefully", () => { @@ -509,46 +489,47 @@ describe("formatIssueDetails", () => { }); // Should not throw - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.length).toBeGreaterThan(5); - expect(lines.some((l) => l.includes("Platform: unknown"))).toBe(true); - expect(lines.some((l) => l.includes("Type: unknown"))).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result.length).toBeGreaterThan(50); + expect(result).toContain("Platform"); + expect(result).toContain("unknown"); + expect(result).toContain("Type"); }); test("includes fixability with percentage when seerFixabilityScore is present", () => { const issue = createMockIssue({ seerFixabilityScore: 0.7 }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("Fixability: High (70%)"))).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Fixability"); + expect(result).toContain("High"); + expect(result).toContain("70%"); }); test("omits fixability when seerFixabilityScore is null", () => { const issue = createMockIssue({ seerFixabilityScore: null }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("Fixability:"))).toBe(false); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).not.toContain("Fixability"); }); test("omits fixability when seerFixabilityScore is undefined", () => { const issue = createMockIssue({ seerFixabilityScore: undefined }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("Fixability:"))).toBe(false); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).not.toContain("Fixability"); }); test("shows med label for medium score", () => { const issue = createMockIssue({ seerFixabilityScore: 0.5 }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("Fixability: Med (50%)"))).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Fixability"); + expect(result).toContain("Med"); + expect(result).toContain("50%"); }); test("shows low label for low score", () => { const issue = createMockIssue({ seerFixabilityScore: 0.1 }); - const lines = formatIssueDetails(issue).map(stripAnsi); - - expect(lines.some((l) => l.includes("Fixability: Low (10%)"))).toBe(true); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Fixability"); + expect(result).toContain("Low"); + expect(result).toContain("10%"); }); }); diff --git a/test/lib/formatters/human.utils.test.ts b/test/lib/formatters/human.utils.test.ts index a0e9ceb6..f44e5d7b 100644 --- a/test/lib/formatters/human.utils.test.ts +++ b/test/lib/formatters/human.utils.test.ts @@ -2,7 +2,7 @@ * Tests for human formatter utility functions * * These tests cover pure utility functions that don't depend on external state. - * Functions tested: formatStatusIcon, formatStatusLabel, formatTable, divider, + * Functions tested: formatStatusIcon, formatStatusLabel, * formatRelativeTime, maskToken, formatDuration, formatExpiration */ @@ -15,13 +15,11 @@ import { stringMatching, } from "fast-check"; import { - divider, formatDuration, formatExpiration, formatRelativeTime, formatStatusIcon, formatStatusLabel, - formatTable, maskToken, } from "../../../src/lib/formatters/human.js"; import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; @@ -88,105 +86,6 @@ describe("formatStatusLabel", () => { }); }); -// Table Formatting - -describe("formatTable", () => { - test("formats simple table with left alignment", () => { - const columns = [ - { header: "NAME", width: 10 }, - { header: "VALUE", width: 5 }, - ]; - const rows = [ - ["Alice", "100"], - ["Bob", "200"], - ]; - - const result = formatTable(columns, rows); - - expect(result).toHaveLength(3); // 1 header + 2 rows - expect(result[0]).toBe("NAME VALUE"); - expect(result[1]).toBe("Alice 100 "); - expect(result[2]).toBe("Bob 200 "); - }); - - test("formats table with right alignment", () => { - const columns = [ - { header: "NAME", width: 10 }, - { header: "COUNT", width: 5, align: "right" as const }, - ]; - const rows = [ - ["Alice", "42"], - ["Bob", "7"], - ]; - - const result = formatTable(columns, rows); - - expect(result[0]).toBe("NAME COUNT"); - expect(result[1]).toBe("Alice 42"); - expect(result[2]).toBe("Bob 7"); - }); - - test("handles empty rows", () => { - const columns = [{ header: "NAME", width: 10 }]; - const rows: string[][] = []; - - const result = formatTable(columns, rows); - - expect(result).toHaveLength(1); // Just header - expect(result[0]).toBe("NAME "); - }); - - test("handles mixed alignment", () => { - const columns = [ - { header: "LEFT", width: 8, align: "left" as const }, - { header: "RIGHT", width: 8, align: "right" as const }, - { header: "DEFAULT", width: 8 }, - ]; - const rows = [["a", "b", "c"]]; - - const result = formatTable(columns, rows); - - expect(result[0]).toBe("LEFT RIGHT DEFAULT "); - expect(result[1]).toBe("a b c "); - }); -}); - -// Divider - -describe("divider", () => { - test("creates divider with default length and character", () => { - const result = stripAnsi(divider()); - expect(result).toBe("─".repeat(80)); - expect(result.length).toBe(80); - }); - - test("creates divider with custom length", () => { - const result = stripAnsi(divider(40)); - expect(result).toBe("─".repeat(40)); - expect(result.length).toBe(40); - }); - - test("creates divider with custom character", () => { - const result = stripAnsi(divider(10, "=")); - expect(result).toBe("=".repeat(10)); - }); - - test("creates divider with both custom length and character", () => { - const result = stripAnsi(divider(5, "*")); - expect(result).toBe("*****"); - }); - - test("property: divider length equals requested length", async () => { - await fcAssert( - property(integer({ min: 1, max: 200 }), (length) => { - const result = stripAnsi(divider(length)); - expect(result.length).toBe(length); - }), - { numRuns: DEFAULT_NUM_RUNS } - ); - }); -}); - // Relative Time Formatting describe("formatRelativeTime", () => { diff --git a/test/lib/formatters/log.property.test.ts b/test/lib/formatters/log.property.test.ts index 42e73f6e..1bb9574a 100644 --- a/test/lib/formatters/log.property.test.ts +++ b/test/lib/formatters/log.property.test.ts @@ -74,15 +74,21 @@ function createDetailedLogArb() { const detailedLogArb = createDetailedLogArb(); +/** Strip ANSI escape codes */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + describe("formatLogDetails properties", () => { - test("always returns non-empty array", async () => { + test("always returns a non-empty string", async () => { await fcAssert( property( detailedLogArb, orgSlugArb, (log: DetailedSentryLog, orgSlug: string) => { const result = formatLogDetails(log, orgSlug); - expect(Array.isArray(result)).toBe(true); + expect(typeof result).toBe("string"); expect(result.length).toBeGreaterThan(0); } ), @@ -96,7 +102,7 @@ describe("formatLogDetails properties", () => { detailedLogArb, orgSlugArb, (log: DetailedSentryLog, orgSlug: string) => { - const result = formatLogDetails(log, orgSlug).join("\n"); + const result = stripAnsi(formatLogDetails(log, orgSlug)); expect(result).toContain(log["sentry.item_id"]); } ), @@ -110,8 +116,8 @@ describe("formatLogDetails properties", () => { detailedLogArb, orgSlugArb, (log: DetailedSentryLog, orgSlug: string) => { - const result = formatLogDetails(log, orgSlug).join("\n"); - expect(result).toContain("Timestamp:"); + const result = stripAnsi(formatLogDetails(log, orgSlug)); + expect(result).toContain("Timestamp"); } ), { numRuns: DEFAULT_NUM_RUNS } @@ -124,8 +130,8 @@ describe("formatLogDetails properties", () => { detailedLogArb, orgSlugArb, (log: DetailedSentryLog, orgSlug: string) => { - const result = formatLogDetails(log, orgSlug).join("\n"); - expect(result).toContain("Severity:"); + const result = stripAnsi(formatLogDetails(log, orgSlug)); + expect(result).toContain("Severity"); } ), { numRuns: DEFAULT_NUM_RUNS } @@ -138,7 +144,7 @@ describe("formatLogDetails properties", () => { detailedLogArb, orgSlugArb, (log: DetailedSentryLog, orgSlug: string) => { - const result = formatLogDetails(log, orgSlug).join("\n"); + const result = stripAnsi(formatLogDetails(log, orgSlug)); if (log.trace) { expect(result).toContain("/traces/"); expect(result).toContain(log.trace); @@ -157,7 +163,7 @@ describe("formatLogDetails properties", () => { detailedLogArb, orgSlugArb, (log: DetailedSentryLog, orgSlug: string) => { - const result = formatLogDetails(log, orgSlug).join("\n"); + const result = stripAnsi(formatLogDetails(log, orgSlug)); if (log["sdk.name"]) { expect(result).toContain("SDK"); expect(result).toContain(log["sdk.name"]); @@ -174,7 +180,7 @@ describe("formatLogDetails properties", () => { detailedLogArb, orgSlugArb, (log: DetailedSentryLog, orgSlug: string) => { - const result = formatLogDetails(log, orgSlug).join("\n"); + const result = stripAnsi(formatLogDetails(log, orgSlug)); const hasCodeFields = log["code.function"] || log["code.file.path"]; if (hasCodeFields) { expect(result).toContain("Source Location"); @@ -202,16 +208,15 @@ describe("formatLogDetails properties", () => { ); }); - test("output lines are all strings", async () => { + test("output is a string (not array)", async () => { await fcAssert( property( detailedLogArb, orgSlugArb, (log: DetailedSentryLog, orgSlug: string) => { const result = formatLogDetails(log, orgSlug); - for (const line of result) { - expect(typeof line).toBe("string"); - } + expect(typeof result).toBe("string"); + expect(Array.isArray(result)).toBe(false); } ), { numRuns: DEFAULT_NUM_RUNS } diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index 0ccb8cba..c1335dd2 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -167,31 +167,29 @@ function createDetailedTestLog( describe("formatLogDetails", () => { test("formats basic log entry with header", () => { const log = createDetailedTestLog(); - const lines = formatLogDetails(log, "test-org"); - const result = lines.join("\n"); + const result = stripAnsi(formatLogDetails(log, "test-org")); - expect(result).toContain("Log test-log-id"); - expect(result).toContain("═"); // Header separator + // Header contains log ID prefix + expect(result).toContain("Log"); + expect(result).toContain("test-log-id"); }); test("includes ID, timestamp, and severity", () => { const log = createDetailedTestLog(); - const lines = formatLogDetails(log, "test-org"); - const result = stripAnsi(lines.join("\n")); + const result = stripAnsi(formatLogDetails(log, "test-org")); - expect(result).toContain("ID:"); + expect(result).toContain("ID"); expect(result).toContain("test-log-id-123456789012345678901234"); - expect(result).toContain("Timestamp:"); - expect(result).toContain("Severity:"); + expect(result).toContain("Timestamp"); + expect(result).toContain("Severity"); expect(result).toContain("INFO"); }); test("includes message when present", () => { const log = createDetailedTestLog({ message: "Custom error message" }); - const lines = formatLogDetails(log, "test-org"); - const result = lines.join("\n"); + const result = formatLogDetails(log, "test-org"); - expect(result).toContain("Message:"); + expect(result).toContain("Message"); expect(result).toContain("Custom error message"); }); @@ -201,15 +199,14 @@ describe("formatLogDetails", () => { environment: "staging", release: "2.0.0", }); - const lines = formatLogDetails(log, "test-org"); - const result = stripAnsi(lines.join("\n")); + const result = stripAnsi(formatLogDetails(log, "test-org")); expect(result).toContain("Context"); - expect(result).toContain("Project:"); + expect(result).toContain("Project"); expect(result).toContain("my-project"); - expect(result).toContain("Environment:"); + expect(result).toContain("Environment"); expect(result).toContain("staging"); - expect(result).toContain("Release:"); + expect(result).toContain("Release"); expect(result).toContain("2.0.0"); }); @@ -218,8 +215,7 @@ describe("formatLogDetails", () => { "sdk.name": "sentry.python", "sdk.version": "2.0.0", }); - const lines = formatLogDetails(log, "test-org"); - const result = stripAnsi(lines.join("\n")); + const result = stripAnsi(formatLogDetails(log, "test-org")); expect(result).toContain("SDK"); expect(result).toContain("sentry.python"); @@ -231,15 +227,14 @@ describe("formatLogDetails", () => { trace: "trace123abc456def789", span_id: "span-abc-123", }); - const lines = formatLogDetails(log, "my-org"); - const result = stripAnsi(lines.join("\n")); + const result = stripAnsi(formatLogDetails(log, "my-org")); expect(result).toContain("Trace"); - expect(result).toContain("Trace ID:"); + expect(result).toContain("Trace ID"); expect(result).toContain("trace123abc456def789"); - expect(result).toContain("Span ID:"); + expect(result).toContain("Span ID"); expect(result).toContain("span-abc-123"); - expect(result).toContain("Link:"); + expect(result).toContain("Link"); expect(result).toContain("my-org/traces/trace123abc456def789"); }); @@ -249,13 +244,12 @@ describe("formatLogDetails", () => { "code.file.path": "src/api/handler.ts", "code.line.number": "42", }); - const lines = formatLogDetails(log, "test-org"); - const result = stripAnsi(lines.join("\n")); + const result = stripAnsi(formatLogDetails(log, "test-org")); expect(result).toContain("Source Location"); - expect(result).toContain("Function:"); + expect(result).toContain("Function"); expect(result).toContain("handleRequest"); - expect(result).toContain("File:"); + expect(result).toContain("File"); expect(result).toContain("src/api/handler.ts:42"); }); @@ -265,15 +259,14 @@ describe("formatLogDetails", () => { "sentry.otel.status_code": "OK", "sentry.otel.instrumentation_scope.name": "express", }); - const lines = formatLogDetails(log, "test-org"); - const result = stripAnsi(lines.join("\n")); + const result = stripAnsi(formatLogDetails(log, "test-org")); expect(result).toContain("OpenTelemetry"); - expect(result).toContain("Kind:"); + expect(result).toContain("Kind"); expect(result).toContain("server"); - expect(result).toContain("Status:"); + expect(result).toContain("Status"); expect(result).toContain("OK"); - expect(result).toContain("Scope:"); + expect(result).toContain("Scope"); expect(result).toContain("express"); }); @@ -287,13 +280,12 @@ describe("formatLogDetails", () => { "sdk.name": null, "sdk.version": null, }); - const lines = formatLogDetails(log, "test-org"); - const result = stripAnsi(lines.join("\n")); + const result = stripAnsi(formatLogDetails(log, "test-org")); // Should still have basic info - expect(result).toContain("ID:"); - expect(result).toContain("Timestamp:"); - expect(result).toContain("Severity:"); + expect(result).toContain("ID"); + expect(result).toContain("Timestamp"); + expect(result).toContain("Severity"); // Should not have optional sections expect(result).not.toContain("Context"); diff --git a/test/lib/formatters/seer.test.ts b/test/lib/formatters/seer.test.ts index ffa75de0..492562e2 100644 --- a/test/lib/formatters/seer.test.ts +++ b/test/lib/formatters/seer.test.ts @@ -10,7 +10,6 @@ import { createSeerError, formatAutofixError, formatProgressLine, - formatRootCause, formatRootCauseList, getProgressMessage, getSpinnerFrame, @@ -19,6 +18,12 @@ import { } from "../../../src/lib/formatters/seer.js"; import type { AutofixState, RootCause } from "../../../src/types/seer.js"; +/** Strip ANSI escape codes */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + describe("getSpinnerFrame", () => { test("returns a spinner character", () => { const frame = getSpinnerFrame(0); @@ -131,61 +136,61 @@ describe("getProgressMessage", () => { }); }); -describe("formatRootCause", () => { +describe("formatRootCauseList", () => { test("formats a basic root cause", () => { - const cause: RootCause = { - id: 0, - description: - "Database connection timeout due to missing pool configuration", - }; + const causes: RootCause[] = [ + { + id: 0, + description: + "Database connection timeout due to missing pool configuration", + }, + ]; - const lines = formatRootCause(cause, 0); - expect(lines.length).toBeGreaterThan(0); - expect(lines.join("\n")).toContain("Database connection timeout"); - expect(lines.join("\n")).toContain("Cause #0"); + const output = stripAnsi(formatRootCauseList(causes)); + expect(output.length).toBeGreaterThan(0); + expect(output).toContain("Database connection timeout"); }); test("includes relevant repos when present", () => { - const cause: RootCause = { - id: 0, - description: "Test cause", - relevant_repos: ["org/repo1", "org/repo2"], - }; + const causes: RootCause[] = [ + { + id: 0, + description: "Test cause", + relevant_repos: ["org/repo1", "org/repo2"], + }, + ]; - const lines = formatRootCause(cause, 0); - const output = lines.join("\n"); + const output = stripAnsi(formatRootCauseList(causes)); expect(output).toContain("org/repo1"); }); test("includes reproduction steps when present", () => { - const cause: RootCause = { - id: 0, - description: "Test cause", - root_cause_reproduction: [ - { - title: "Step 1", - code_snippet_and_analysis: "User makes API request", - }, - { - title: "Step 2", - code_snippet_and_analysis: "Database query times out", - }, - ], - }; + const causes: RootCause[] = [ + { + id: 0, + description: "Test cause", + root_cause_reproduction: [ + { + title: "Step 1", + code_snippet_and_analysis: "User makes API request", + }, + { + title: "Step 2", + code_snippet_and_analysis: "Database query times out", + }, + ], + }, + ]; - const lines = formatRootCause(cause, 0); - const output = lines.join("\n"); + const output = stripAnsi(formatRootCauseList(causes)); expect(output).toContain("Step 1"); expect(output).toContain("User makes API request"); }); -}); -describe("formatRootCauseList", () => { test("formats single cause", () => { const causes: RootCause[] = [{ id: 0, description: "Single root cause" }]; - const lines = formatRootCauseList(causes); - const output = lines.join("\n"); + const output = stripAnsi(formatRootCauseList(causes)); expect(output).toContain("Single root cause"); }); @@ -195,15 +200,13 @@ describe("formatRootCauseList", () => { { id: 1, description: "Second cause" }, ]; - const lines = formatRootCauseList(causes); - const output = lines.join("\n"); + const output = stripAnsi(formatRootCauseList(causes)); expect(output).toContain("First cause"); expect(output).toContain("Second cause"); }); test("handles empty causes array", () => { - const lines = formatRootCauseList([]); - const output = lines.join("\n"); + const output = stripAnsi(formatRootCauseList([])); expect(output).toContain("No root causes"); }); }); diff --git a/test/lib/formatters/table.test.ts b/test/lib/formatters/table.test.ts index 5c3c968e..d8664262 100644 --- a/test/lib/formatters/table.test.ts +++ b/test/lib/formatters/table.test.ts @@ -1,5 +1,8 @@ /** * Tests for the generic table renderer. + * + * writeTable now renders via marked-terminal producing Unicode box-drawing + * tables. Tests verify content is present rather than exact text alignment. */ import { describe, expect, mock, test } from "bun:test"; @@ -13,14 +16,20 @@ const columns: Column[] = [ { header: "STATUS", value: (r) => r.status }, ]; +/** Strip ANSI escape codes */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + function capture(items: Row[], cols = columns): string { const write = mock(() => true); writeTable({ write }, items, cols); - return write.mock.calls.map((c) => c[0]).join(""); + return stripAnsi(write.mock.calls.map((c) => c[0]).join("")); } describe("writeTable", () => { - test("renders header and rows with auto-sized columns", () => { + test("renders header and rows with content", () => { const output = capture([ { name: "alpha", count: 42, status: "active" }, { name: "beta-longer", count: 7, status: "inactive" }, @@ -40,30 +49,17 @@ describe("writeTable", () => { expect(output).toContain("inactive"); }); - test("right-aligns columns when specified", () => { + test("all column values appear in rendered output", () => { const output = capture([ { name: "a", count: 1, status: "ok" }, { name: "b", count: 999, status: "ok" }, ]); - const lines = output.split("\n").filter(Boolean); - // The COUNT column should have right-aligned values - // Header: "COUNT" is 5 chars, max value "999" is 3 chars, so width = 5 - // "1" should be padded: " 1" (5 chars, right-aligned) - const headerLine = lines[0]!; - const countHeaderIdx = headerLine.indexOf("COUNT"); - expect(countHeaderIdx).toBeGreaterThan(-1); - - // Row with count=1 should have right-padding before count - const dataLine1 = lines[1]!; - const countSlice1 = dataLine1.slice( - countHeaderIdx, - countHeaderIdx + "COUNT".length - ); - expect(countSlice1.trim()).toBe("1"); + expect(output).toContain("1"); + expect(output).toContain("999"); }); - test("respects minWidth for columns", () => { + test("respects minWidth — values appear in output", () => { const cols: Column[] = [ { header: "N", value: (r) => r.name, minWidth: 10 }, { header: "C", value: (r) => String(r.count) }, @@ -71,21 +67,17 @@ describe("writeTable", () => { ]; const output = capture([{ name: "x", count: 1, status: "y" }], cols); - const lines = output.split("\n").filter(Boolean); - // Header "N" should be padded to at least 10 chars - const headerLine = lines[0]!; - const firstColEnd = headerLine.indexOf(" C"); - // First column should be at least 10 chars wide - expect(firstColEnd).toBeGreaterThanOrEqual(10); + expect(output).toContain("N"); + expect(output).toContain("x"); + expect(output).toContain("1"); + expect(output).toContain("y"); }); - test("handles empty items array (header only)", () => { + test("handles empty items array — only headers rendered", () => { const output = capture([]); - const lines = output.split("\n").filter(Boolean); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain("NAME"); - expect(lines[0]).toContain("COUNT"); - expect(lines[0]).toContain("STATUS"); + expect(output).toContain("NAME"); + expect(output).toContain("COUNT"); + expect(output).toContain("STATUS"); }); test("column width respects header length even with short values", () => { @@ -94,11 +86,8 @@ describe("writeTable", () => { ]; const write = mock(() => true); writeTable({ write }, [{ v: "x" }], cols); - const output = write.mock.calls.map((c) => c[0]).join(""); - const lines = output.split("\n").filter(Boolean); - // Header line should have the full header - expect(lines[0]).toContain("VERY_LONG_HEADER"); - // Data line should be padded to header width - expect(lines[1]!.length).toBeGreaterThanOrEqual("VERY_LONG_HEADER".length); + const output = stripAnsi(write.mock.calls.map((c) => c[0]).join("")); + expect(output).toContain("VERY_LONG_HEADER"); + expect(output).toContain("x"); }); }); diff --git a/test/lib/formatters/trace.property.test.ts b/test/lib/formatters/trace.property.test.ts index 236fe2bf..61f0e956 100644 --- a/test/lib/formatters/trace.property.test.ts +++ b/test/lib/formatters/trace.property.test.ts @@ -259,12 +259,18 @@ describe("property: computeTraceSummary", () => { }); }); +/** Strip ANSI escape codes */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + describe("property: formatTraceSummary", () => { test("always contains the trace ID", () => { fcAssert( property(hexId32Arb, spanListArb, (traceId, spans) => { const summary = computeTraceSummary(traceId, spans); - const output = formatTraceSummary(summary).join("\n"); + const output = stripAnsi(formatTraceSummary(summary)); expect(output).toContain(traceId); }), { numRuns: DEFAULT_NUM_RUNS } @@ -275,33 +281,31 @@ describe("property: formatTraceSummary", () => { fcAssert( property(hexId32Arb, spanListArb, (traceId, spans) => { const summary = computeTraceSummary(traceId, spans); - const output = formatTraceSummary(summary).join("\n"); - expect(output).toContain("Duration:"); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Duration"); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("always contains Span Count label", () => { + test("always contains Spans label", () => { fcAssert( property(hexId32Arb, spanListArb, (traceId, spans) => { const summary = computeTraceSummary(traceId, spans); - const output = formatTraceSummary(summary).join("\n"); - expect(output).toContain("Span Count:"); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Spans"); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("returns array of strings", () => { + test("returns a string", () => { fcAssert( property(hexId32Arb, spanListArb, (traceId, spans) => { const summary = computeTraceSummary(traceId, spans); const result = formatTraceSummary(summary); - expect(Array.isArray(result)).toBe(true); - for (const line of result) { - expect(typeof line).toBe("string"); - } + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); }), { numRuns: DEFAULT_NUM_RUNS } ); diff --git a/test/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index cfd6a8d6..52c656e8 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -250,7 +250,7 @@ describe("formatTraceSummary", () => { const summary = computeTraceSummary("abc123def456", [ makeSpan({ start_timestamp: 1000.0, timestamp: 1001.0 }), ]); - const output = stripAnsi(formatTraceSummary(summary).join("\n")); + const output = stripAnsi(formatTraceSummary(summary)); expect(output).toContain("abc123def456"); }); @@ -261,16 +261,17 @@ describe("formatTraceSummary", () => { "transaction.op": "http.server", }), ]); - const output = stripAnsi(formatTraceSummary(summary).join("\n")); - expect(output).toContain("[http.server] GET /api/users"); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("http.server"); + expect(output).toContain("GET /api/users"); }); test("shows duration", () => { const summary = computeTraceSummary("trace-id", [ makeSpan({ start_timestamp: 1000.0, timestamp: 1001.24 }), ]); - const output = stripAnsi(formatTraceSummary(summary).join("\n")); - expect(output).toContain("Duration:"); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Duration"); expect(output).toContain("1.24s"); }); @@ -278,8 +279,8 @@ describe("formatTraceSummary", () => { const summary = computeTraceSummary("trace-id", [ makeSpan({ start_timestamp: 0, timestamp: 0 }), ]); - const output = stripAnsi(formatTraceSummary(summary).join("\n")); - expect(output).toContain("Duration:"); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Duration"); expect(output).toContain("—"); }); @@ -287,16 +288,17 @@ describe("formatTraceSummary", () => { const summary = computeTraceSummary("trace-id", [ makeSpan({ children: [makeSpan(), makeSpan()] }), ]); - const output = stripAnsi(formatTraceSummary(summary).join("\n")); - expect(output).toContain("Span Count: 3"); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Spans"); + expect(output).toContain("3"); }); test("shows projects when present", () => { const summary = computeTraceSummary("trace-id", [ makeSpan({ project_slug: "my-app" }), ]); - const output = stripAnsi(formatTraceSummary(summary).join("\n")); - expect(output).toContain("Projects:"); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Projects"); expect(output).toContain("my-app"); }); @@ -307,15 +309,15 @@ describe("formatTraceSummary", () => { timestamp: 1_700_000_001.0, }), ]); - const output = stripAnsi(formatTraceSummary(summary).join("\n")); - expect(output).toContain("Started:"); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Started"); }); test("omits start time when no valid timestamps", () => { const summary = computeTraceSummary("trace-id", [ makeSpan({ start_timestamp: 0, timestamp: 0 }), ]); - const output = stripAnsi(formatTraceSummary(summary).join("\n")); - expect(output).not.toContain("Started:"); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).not.toContain("Started"); }); }); From cd7301ff2b03f483f145dde58c5ca9077978564c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 11:44:13 +0000 Subject: [PATCH 02/52] fix(deps): move marked/marked-terminal to devDependencies --- bun.lock | 6 ++--- package.json | 62 +++++++++++++++++++++++++--------------------------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/bun.lock b/bun.lock index 13b8be05..4dc6b250 100644 --- a/bun.lock +++ b/bun.lock @@ -4,10 +4,6 @@ "workspaces": { "": { "name": "sentry", - "dependencies": { - "marked": "^15", - "marked-terminal": "^7.3.0", - }, "devDependencies": { "@biomejs/biome": "2.3.8", "@sentry/api": "^0.1.0", @@ -26,6 +22,8 @@ "esbuild": "^0.25.0", "fast-check": "^4.5.3", "ignore": "^7.0.5", + "marked": "^15", + "marked-terminal": "^7.3.0", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", diff --git a/package.json b/package.json index 0e3be15a..1a0e6d27 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,9 @@ { "name": "sentry", - "version": "0.14.0-dev.0", - "description": "Sentry CLI - A command-line interface for using Sentry built by robots and humans for robots and humans", - "type": "module", - "bin": { - "sentry": "./dist/bin.cjs" - }, - "files": [ - "dist/bin.cjs" - ], - "scripts": { - "dev": "bun run src/bin.ts", - "build": "bun run script/build.ts --single", - "build:all": "bun run script/build.ts", - "bundle": "bun run script/bundle.ts", - "typecheck": "tsc --noEmit", - "lint": "bunx ultracite check", - "lint:fix": "bunx ultracite fix", - "test": "bun run test:unit && bun run test:isolated", - "test:unit": "bun test test/lib test/commands test/types --coverage --coverage-reporter=lcov", - "test:isolated": "bun test test/isolated", - "test:e2e": "bun test test/e2e", - "generate:skill": "bun run script/generate-skill.ts", - "check:skill": "bun run script/check-skill.ts", - "check:deps": "bun run script/check-no-deps.ts" + "version": "0.13.0-dev.0", + "repository": { + "type": "git", + "url": "https://github.com/getsentry/cli.git" }, "devDependencies": { "@biomejs/biome": "2.3.8", @@ -43,6 +23,8 @@ "esbuild": "^0.25.0", "fast-check": "^4.5.3", "ignore": "^7.0.5", + "marked": "^15", + "marked-terminal": "^7.3.0", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", @@ -53,20 +35,36 @@ "uuidv7": "^1.1.0", "zod": "^3.24.0" }, - "repository": { - "type": "git", - "url": "https://github.com/getsentry/cli.git" + "bin": { + "sentry": "./dist/bin.cjs" }, - "license": "FSL-1.1-Apache-2.0", + "description": "Sentry CLI - A command-line interface for using Sentry built by robots and humans for robots and humans", "engines": { "node": ">=22" }, + "files": [ + "dist/bin.cjs" + ], + "license": "FSL-1.1-Apache-2.0", "packageManager": "bun@1.3.9", "patchedDependencies": { "@stricli/core@1.2.5": "patches/@stricli%2Fcore@1.2.5.patch" }, - "dependencies": { - "marked": "^15", - "marked-terminal": "^7.3.0" - } + "scripts": { + "dev": "bun run src/bin.ts", + "build": "bun run script/build.ts --single", + "build:all": "bun run script/build.ts", + "bundle": "bun run script/bundle.ts", + "typecheck": "tsc --noEmit", + "lint": "bunx ultracite check", + "lint:fix": "bunx ultracite fix", + "test": "bun run test:unit && bun run test:isolated", + "test:unit": "bun test test/lib test/commands test/types --coverage --coverage-reporter=lcov", + "test:isolated": "bun test test/isolated", + "test:e2e": "bun test test/e2e", + "generate:skill": "bun run script/generate-skill.ts", + "check:skill": "bun run script/check-skill.ts", + "check:deps": "bun run script/check-no-deps.ts" + }, + "type": "module" } From dcb857623d688ae5a05bf3588181332252f35a16 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 11:52:45 +0000 Subject: [PATCH 03/52] fix(formatters): properly escape backslashes and pipes in markdown table cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add escapeMarkdownCell() helper to markdown.ts that escapes backslashes first, then pipe characters — fixing CodeQL 'Incomplete string escaping' alerts where the previous \| replacement left backslashes unescaped. --- src/lib/formatters/human.ts | 13 +++++-------- src/lib/formatters/log.ts | 2 +- src/lib/formatters/markdown.ts | 13 +++++++++++++ src/lib/formatters/trace.ts | 4 ++-- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 9f70a901..00ed0c36 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -32,7 +32,7 @@ import { muted, statusColor, } from "./colors.js"; -import { renderMarkdown } from "./markdown.js"; +import { escapeMarkdownCell, renderMarkdown } from "./markdown.js"; // Status Formatting @@ -806,12 +806,9 @@ function buildBreadcrumbsMarkdown(breadcrumbsEntry: BreadcrumbsEntry): string { message = `${message.slice(0, 77)}...`; } - // Escape pipe characters that would break the markdown table - const safeMessage = message.replace(/\|/g, "\\|"); - const safeCategory = (breadcrumb.category ?? "default").replace( - /\|/g, - "\\|" - ); + // Escape special markdown characters that would break the table cell + const safeMessage = escapeMarkdownCell(message); + const safeCategory = escapeMarkdownCell(breadcrumb.category ?? "default"); lines.push( `| ${timestamp} | ${level} | ${safeCategory} | ${safeMessage} |` @@ -1226,7 +1223,7 @@ export function formatEventDetails( sections.push("| Key | Value |"); sections.push("|---|---|"); for (const tag of event.tags) { - const safeVal = String(tag.value).replace(/\|/g, "\\|"); + const safeVal = escapeMarkdownCell(String(tag.value)); sections.push(`| \`${tag.key}\` | ${safeVal} |`); } } diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 1d762964..e280eb44 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -96,7 +96,7 @@ export function formatLogTable(logs: SentryLog[]): string { const timestamp = formatTimestamp(log.timestamp); // Pre-render ANSI severity color — survives the cli-table3 pipeline const severity = formatSeverity(log.severity).trim(); - const message = (log.message ?? "").replace(/\|/g, "\\|"); + const message = escapeMarkdownCell(log.message ?? ""); const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; return `| ${timestamp} | ${severity} | ${message}${trace} |`; }) diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 8215c802..e0d4122b 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -67,6 +67,19 @@ marked.use( }) ); +/** + * Escape a string for safe use inside a markdown table cell. + * + * Escapes backslashes first (so the escape character itself is not + * double-interpreted), then pipe characters (the table cell delimiter). + * + * @param value - Raw cell content + * @returns Markdown-safe string suitable for embedding in `| cell |` syntax + */ +export function escapeMarkdownCell(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); +} + /** * Render a markdown string as styled terminal output. * diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 99f7261c..a7f4c58f 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -98,8 +98,8 @@ export function formatTraceTable(items: TransactionListItem[]): string { const transaction = item.transaction || "unknown"; const duration = formatTraceDuration(item["transaction.duration"]); const when = formatRelativeTime(item.timestamp).trim(); - // Escape pipe characters in cell values to avoid breaking the table - const safeTransaction = transaction.replace(/\|/g, "\\|"); + // Escape special markdown characters in cell values to avoid breaking the table + const safeTransaction = escapeMarkdownCell(transaction); return `| \`${traceId}\` | ${safeTransaction} | ${duration} | ${when} |`; }) .join("\n"); From befef7e3bff8d4817e0c9417b933dec02ae3f6d7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 11:55:47 +0000 Subject: [PATCH 04/52] fix(formatters): add missing escapeMarkdownCell imports in trace.ts and log.ts --- src/lib/formatters/log.ts | 2 +- src/lib/formatters/trace.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index e280eb44..30e2c03a 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -7,7 +7,7 @@ import type { DetailedSentryLog, SentryLog } from "../../types/index.js"; import { buildTraceUrl } from "../sentry-urls.js"; import { cyan, muted, red, yellow } from "./colors.js"; -import { renderMarkdown } from "./markdown.js"; +import { escapeMarkdownCell, renderMarkdown } from "./markdown.js"; /** Color functions for log severity levels */ const SEVERITY_COLORS: Record string> = { diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index a7f4c58f..5fe81bf4 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -7,7 +7,7 @@ import type { TraceSpan, TransactionListItem } from "../../types/index.js"; import { muted } from "./colors.js"; import { formatRelativeTime } from "./human.js"; -import { renderMarkdown } from "./markdown.js"; +import { escapeMarkdownCell, renderMarkdown } from "./markdown.js"; /** * Format a duration in milliseconds to a human-readable string. From 78dd5e1a7cd46c46dd9930001f26669ad5cba4e8 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 12:41:42 +0000 Subject: [PATCH 05/52] feat(formatters): add plain output mode with isTTY detection and env var overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate all markdown rendering behind isPlainOutput(): skip marked.parse() and return raw CommonMark when stdout is not a TTY. Override with env vars: - SENTRY_PLAIN_OUTPUT=1/0 — explicit project-specific control (highest priority) - NO_COLOR=1/0 — widely-supported standard (secondary) - process.stdout.isTTY — auto-detect (fallback) Both env vars treat '0', 'false', '' as falsy; everything else as truthy (case-insensitive). SENTRY_PLAIN_OUTPUT takes precedence over NO_COLOR. Add renderInlineMarkdown() using marked.parseInline() for inline-only rendering of individual cell values without block wrapping. Migrate streaming formatters to dual-mode output: - formatLogRow / formatLogsHeader: plain emits markdown table rows/header - formatTraceRow / formatTracesHeader: same This means piping to a file produces valid CommonMark; live TTY sessions get the existing ANSI-rendered output unchanged. --- src/lib/formatters/log.ts | 33 +++- src/lib/formatters/markdown.ts | 78 +++++++- src/lib/formatters/trace.ts | 28 ++- test/lib/formatters/log.test.ts | 101 +++++++++- test/lib/formatters/markdown.test.ts | 274 +++++++++++++++++++++++++++ test/lib/formatters/trace.test.ts | 106 ++++++++++- 6 files changed, 602 insertions(+), 18 deletions(-) create mode 100644 test/lib/formatters/markdown.test.ts diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 30e2c03a..e3ce0822 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -7,7 +7,12 @@ import type { DetailedSentryLog, SentryLog } from "../../types/index.js"; import { buildTraceUrl } from "../sentry-urls.js"; import { cyan, muted, red, yellow } from "./colors.js"; -import { escapeMarkdownCell, renderMarkdown } from "./markdown.js"; +import { + escapeMarkdownCell, + isPlainOutput, + renderInlineMarkdown, + renderMarkdown, +} from "./markdown.js"; /** Color functions for log severity levels */ const SEVERITY_COLORS: Record string> = { @@ -55,27 +60,45 @@ function formatTimestamp(timestamp: string): string { /** * Format a single log entry for human-readable output. * - * Format: "TIMESTAMP SEVERITY MESSAGE [trace_id]" - * Example: "2024-01-30 14:32:15 ERROR Failed to connect [abc12345]" + * In plain mode (non-TTY / `SENTRY_PLAIN_OUTPUT=1`): emits a markdown table + * row so streamed output composes into a valid CommonMark document. + * In rendered mode (TTY): emits padded ANSI-colored text for live display. * * @param log - The log entry to format * @returns Formatted log line with newline */ export function formatLogRow(log: SentryLog): string { + if (isPlainOutput()) { + const timestamp = formatTimestamp(log.timestamp); + const severity = renderInlineMarkdown( + `**${(log.severity ?? "info").toUpperCase()}**` + ); + const message = escapeMarkdownCell(log.message ?? ""); + const trace = log.trace + ? ` ${renderInlineMarkdown(`\`[${log.trace.slice(0, 8)}]\``)}` + : ""; + return `| ${timestamp} | ${severity} | ${message}${trace} |\n`; + } + const timestamp = formatTimestamp(log.timestamp); const severity = formatSeverity(log.severity); const message = log.message ?? ""; const trace = log.trace ? muted(` [${log.trace.slice(0, 8)}]`) : ""; - return `${timestamp} ${severity} ${message}${trace}\n`; } /** * Format column header for logs list (used in streaming/follow mode). * - * @returns Header line with column titles and separator + * In plain mode: emits a markdown table header + separator row. + * In rendered mode: emits an ANSI-muted text header with a rule separator. + * + * @returns Header string (includes trailing newline) */ export function formatLogsHeader(): string { + if (isPlainOutput()) { + return "| Timestamp | Level | Message |\n| --- | --- | --- |\n"; + } const header = muted("TIMESTAMP LEVEL MESSAGE"); return `${header}\n${muted("─".repeat(80))}\n`; } diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index e0d4122b..9a29c5ff 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -2,12 +2,22 @@ * Markdown-to-Terminal Renderer * * Central utility for rendering markdown content as styled terminal output - * using `marked` + `marked-terminal`. Provides a single `renderMarkdown()` - * function that all formatters can use for rich text output. + * using `marked` + `marked-terminal`. Provides `renderMarkdown()` and + * `renderInlineMarkdown()` for rich text output, with automatic plain-mode + * fallback when stdout is not a TTY or the user has opted out of rich output. * * Pre-rendered ANSI escape codes embedded in markdown source (e.g. inside * table cells) survive the pipeline — `cli-table3` computes column widths * via `string-width`, which correctly treats ANSI codes as zero-width. + * + * ## Output mode resolution (highest → lowest priority) + * + * 1. `SENTRY_PLAIN_OUTPUT=1` → plain (raw CommonMark) + * 2. `SENTRY_PLAIN_OUTPUT=0` → rendered (force rich, even when piped) + * 3. `NO_COLOR=1` (or any truthy value) → plain + * 4. `NO_COLOR=0` (or any falsy value) → rendered + * 5. `!process.stdout.isTTY` → plain + * 6. default (TTY, no overrides) → rendered */ import chalk from "chalk"; @@ -67,6 +77,43 @@ marked.use( }) ); +/** + * Returns true if an env var value should be treated as "truthy" for + * purposes of enabling/disabling output modes. + * + * Falsy values: `"0"`, `"false"`, `""` (case-insensitive). + * Everything else (e.g. `"1"`, `"true"`, `"yes"`) is truthy. + */ +function isTruthyEnv(val: string): boolean { + const normalized = val.toLowerCase().trim(); + return normalized !== "0" && normalized !== "false" && normalized !== ""; +} + +/** + * Determines whether output should be plain CommonMark markdown (no ANSI). + * + * Evaluated fresh on each call so tests can flip env vars between assertions + * and changes to `process.stdout.isTTY` are picked up immediately. + * + * Priority (highest first): + * 1. `SENTRY_PLAIN_OUTPUT` — explicit project-specific override + * 2. `NO_COLOR` — widely-supported standard for disabling styled output + * 3. `process.stdout.isTTY` — auto-detect interactive terminal + */ +export function isPlainOutput(): boolean { + const plain = process.env.SENTRY_PLAIN_OUTPUT; + if (plain !== undefined) { + return isTruthyEnv(plain); + } + + const noColor = process.env.NO_COLOR; + if (noColor !== undefined) { + return isTruthyEnv(noColor); + } + + return !process.stdout.isTTY; +} + /** * Escape a string for safe use inside a markdown table cell. * @@ -81,7 +128,8 @@ export function escapeMarkdownCell(value: string): string { } /** - * Render a markdown string as styled terminal output. + * Render a full markdown document as styled terminal output, or return the + * raw CommonMark string when in plain mode. * * Supports the full CommonMark spec: * - Headings, bold, italic, strikethrough @@ -96,8 +144,30 @@ export function escapeMarkdownCell(value: string): string { * Pre-rendered ANSI escape codes in the input are preserved. * * @param md - Markdown source text - * @returns Styled terminal string with trailing whitespace trimmed + * @returns Styled terminal string (TTY) or raw CommonMark (non-TTY / plain mode) */ export function renderMarkdown(md: string): string { + if (isPlainOutput()) { + return md.trimEnd(); + } return (marked.parse(md) as string).trimEnd(); } + +/** + * Render inline markdown (bold, code spans, emphasis, links) as styled + * terminal output, or return the raw markdown string when in plain mode. + * + * Unlike `renderMarkdown()`, this uses `marked.parseInline()` which handles + * only inline-level constructs — no paragraph wrapping, no block elements. + * Suitable for styling individual table cell values in streaming formatters + * that write rows incrementally rather than as a complete table. + * + * @param md - Inline markdown text + * @returns Styled string (TTY) or raw markdown text (non-TTY / plain mode) + */ +export function renderInlineMarkdown(md: string): string { + if (isPlainOutput()) { + return md; + } + return marked.parseInline(md) as string; +} diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 5fe81bf4..0a944d96 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -7,7 +7,12 @@ import type { TraceSpan, TransactionListItem } from "../../types/index.js"; import { muted } from "./colors.js"; import { formatRelativeTime } from "./human.js"; -import { escapeMarkdownCell, renderMarkdown } from "./markdown.js"; +import { + escapeMarkdownCell, + isPlainOutput, + renderInlineMarkdown, + renderMarkdown, +} from "./markdown.js"; /** * Format a duration in milliseconds to a human-readable string. @@ -44,9 +49,15 @@ export function formatTraceDuration(ms: number): string { /** * Format column header for traces list (used before per-row output). * - * @returns Header line with column titles and separator + * In plain mode: emits a markdown table header + separator row. + * In rendered mode: emits an ANSI-muted text header with a rule separator. + * + * @returns Header string (includes trailing newline) */ export function formatTracesHeader(): string { + if (isPlainOutput()) { + return "| Trace ID | Transaction | Duration | When |\n| --- | --- | ---: | --- |\n"; + } const header = muted( "TRACE ID TRANSACTION DURATION WHEN" ); @@ -65,10 +76,22 @@ const DURATION_WIDTH = 10; /** * Format a single transaction row for the traces list. * + * In plain mode (non-TTY / `SENTRY_PLAIN_OUTPUT=1`): emits a markdown table + * row so streamed output composes into a valid CommonMark document. + * In rendered mode (TTY): emits padded ANSI-colored text for live display. + * * @param item - Transaction list item from the API * @returns Formatted row string with newline */ export function formatTraceRow(item: TransactionListItem): string { + if (isPlainOutput()) { + const traceId = renderInlineMarkdown(`\`${item.trace}\``); + const transaction = escapeMarkdownCell(item.transaction || "unknown"); + const duration = formatTraceDuration(item["transaction.duration"]); + const when = formatRelativeTime(item.timestamp).trim(); + return `| ${traceId} | ${transaction} | ${duration} | ${when} |\n`; + } + const traceId = item.trace.slice(0, TRACE_ID_WIDTH).padEnd(TRACE_ID_WIDTH); const transaction = (item.transaction || "unknown") .slice(0, MAX_TRANSACTION_LENGTH) @@ -77,7 +100,6 @@ export function formatTraceRow(item: TransactionListItem): string { DURATION_WIDTH ); const when = formatRelativeTime(item.timestamp); - return `${traceId} ${transaction} ${duration} ${when}\n`; } diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index c1335dd2..97fd961e 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -2,7 +2,7 @@ * Tests for log formatters */ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { formatLogDetails, formatLogRow, @@ -10,6 +10,38 @@ import { } from "../../../src/lib/formatters/log.js"; import type { DetailedSentryLog, SentryLog } from "../../../src/types/index.js"; +/** Force rendered (TTY) mode for a describe block */ +function useRenderedMode() { + let savedPlain: string | undefined; + beforeEach(() => { + savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + }); + afterEach(() => { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + }); +} + +/** Force plain mode for a describe block */ +function usePlainMode() { + let savedPlain: string | undefined; + beforeEach(() => { + savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "1"; + }); + afterEach(() => { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + }); +} + function createTestLog(overrides: Partial = {}): SentryLog { return { "sentry.item_id": "test-id-123", @@ -28,7 +60,9 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, ""); } -describe("formatLogRow", () => { +describe("formatLogRow (rendered mode)", () => { + useRenderedMode(); + test("formats basic log entry", () => { const log = createTestLog(); const result = formatLogRow(log); @@ -116,7 +150,9 @@ describe("formatLogRow", () => { }); }); -describe("formatLogsHeader", () => { +describe("formatLogsHeader (rendered mode)", () => { + useRenderedMode(); + test("contains column titles", () => { const result = stripAnsi(formatLogsHeader()); @@ -138,6 +174,65 @@ describe("formatLogsHeader", () => { }); }); +describe("formatLogRow (plain mode)", () => { + usePlainMode(); + + test("emits a markdown table row", () => { + const log = createTestLog(); + const result = formatLogRow(log); + expect(result).toMatch(/^\|.+\|.+\|.+\|\n$/); + }); + + test("contains timestamp, severity, message", () => { + const log = createTestLog({ + severity: "error", + message: "connection failed", + }); + const result = formatLogRow(log); + expect(result).toContain("connection failed"); + expect(result).toContain("ERROR"); + expect(result).toMatch(/\d{4}-\d{2}-\d{2}/); + }); + + test("contains trace ID as inline code", () => { + const log = createTestLog({ trace: "abc123def456" }); + const result = formatLogRow(log); + expect(result).toContain("[abc123de]"); + }); + + test("omits trace cell when trace is null", () => { + const log = createTestLog({ trace: null }); + const result = formatLogRow(log); + expect(result).not.toContain("["); + }); + + test("escapes pipe characters in message", () => { + const log = createTestLog({ message: "a|b" }); + const result = formatLogRow(log); + // Raw pipe in message must be escaped so it doesn't break the table + expect(result).toContain("a\\|b"); + }); + + test("ends with newline", () => { + const result = formatLogRow(createTestLog()); + expect(result).toEndWith("\n"); + }); +}); + +describe("formatLogsHeader (plain mode)", () => { + usePlainMode(); + + test("emits markdown table header and separator", () => { + const result = formatLogsHeader(); + expect(result).toContain("| Timestamp | Level | Message |"); + expect(result).toContain("| --- | --- | --- |"); + }); + + test("ends with newline", () => { + expect(formatLogsHeader()).toEndWith("\n"); + }); +}); + function createDetailedTestLog( overrides: Partial = {} ): DetailedSentryLog { diff --git a/test/lib/formatters/markdown.test.ts b/test/lib/formatters/markdown.test.ts new file mode 100644 index 00000000..e39abf5f --- /dev/null +++ b/test/lib/formatters/markdown.test.ts @@ -0,0 +1,274 @@ +/** + * Tests for markdown.ts rendering mode logic. + * + * Tests cover isPlainOutput() priority chain, env var truthy/falsy + * normalization, and the gating behaviour of renderMarkdown() / + * renderInlineMarkdown(). + */ + +import { describe, expect, test } from "bun:test"; +import { + isPlainOutput, + renderInlineMarkdown, + renderMarkdown, +} from "../../../src/lib/formatters/markdown.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Strip ANSI escape codes for content-only assertions */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +/** Save and restore env vars + isTTY around each test */ +function withEnv( + vars: Partial>, + isTTY: boolean | undefined, + fn: () => void +): void { + const savedEnv: Record = {}; + const savedTTY = process.stdout.isTTY; + + for (const [key, val] of Object.entries(vars)) { + savedEnv[key] = process.env[key]; + if (val === undefined) { + delete process.env[key]; + } else { + process.env[key] = val; + } + } + process.stdout.isTTY = isTTY as boolean; + + try { + fn(); + } finally { + for (const [key, val] of Object.entries(savedEnv)) { + if (val === undefined) { + delete process.env[key]; + } else { + process.env[key] = val; + } + } + process.stdout.isTTY = savedTTY; + } +} + +// --------------------------------------------------------------------------- +// isPlainOutput() +// --------------------------------------------------------------------------- + +describe("isPlainOutput", () => { + describe("SENTRY_PLAIN_OUTPUT takes highest priority", () => { + test("=1 → plain, regardless of isTTY", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, true, () => { + expect(isPlainOutput()).toBe(true); + }); + }); + + test("=true → plain", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: "true", NO_COLOR: undefined }, + true, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + test("=TRUE → plain (case-insensitive)", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: "TRUE", NO_COLOR: undefined }, + true, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + test("=0 → rendered, even when not a TTY", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + expect(isPlainOutput()).toBe(false); + }); + }); + + test("=false → rendered", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: "false", NO_COLOR: undefined }, + false, + () => { + expect(isPlainOutput()).toBe(false); + } + ); + }); + + test("=FALSE → rendered (case-insensitive)", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: "FALSE", NO_COLOR: undefined }, + false, + () => { + expect(isPlainOutput()).toBe(false); + } + ); + }); + + test("='' → rendered (empty string is falsy)", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "", NO_COLOR: undefined }, false, () => { + expect(isPlainOutput()).toBe(false); + }); + }); + + test("SENTRY_PLAIN_OUTPUT=0 overrides NO_COLOR=1", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: "1" }, false, () => { + expect(isPlainOutput()).toBe(false); + }); + }); + + test("SENTRY_PLAIN_OUTPUT=1 overrides NO_COLOR=0", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: "0" }, true, () => { + expect(isPlainOutput()).toBe(true); + }); + }); + }); + + describe("NO_COLOR as secondary override (SENTRY_PLAIN_OUTPUT unset)", () => { + test("=1 → plain", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "1" }, true, () => { + expect(isPlainOutput()).toBe(true); + }); + }); + + test("=true → plain (case-insensitive)", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "True" }, + true, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + test("=0 → rendered", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "0" }, false, () => { + expect(isPlainOutput()).toBe(false); + }); + }); + + test("=false → rendered (case-insensitive)", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "false" }, + false, + () => { + expect(isPlainOutput()).toBe(false); + } + ); + }); + + test("='' → rendered (empty string is falsy)", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "" }, false, () => { + expect(isPlainOutput()).toBe(false); + }); + }); + }); + + describe("isTTY fallback (both env vars unset)", () => { + test("non-TTY → plain", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: undefined }, + false, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + test("TTY → rendered", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: undefined }, + true, + () => { + expect(isPlainOutput()).toBe(false); + } + ); + }); + + test("isTTY=undefined → plain", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: undefined }, + undefined, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + }); +}); + +// --------------------------------------------------------------------------- +// renderMarkdown() +// --------------------------------------------------------------------------- + +describe("renderMarkdown", () => { + test("plain mode: returns raw markdown trimmed", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, false, () => { + const md = "## Hello\n\n| A | B |\n|---|---|\n| 1 | 2 |\n"; + expect(renderMarkdown(md)).toBe(md.trimEnd()); + }); + }); + + test("rendered mode: returns ANSI-styled output (not raw markdown)", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = renderMarkdown("**bold text**"); + // Should contain ANSI codes or at minimum not be the raw markdown + // (chalk may produce no ANSI in test env — check trimEnd at minimum) + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + }); + + test("plain mode: trailing whitespace is trimmed", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, false, () => { + expect(renderMarkdown("hello\n\n\n")).toBe("hello"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// renderInlineMarkdown() +// --------------------------------------------------------------------------- + +describe("renderInlineMarkdown", () => { + test("plain mode: returns input unchanged", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, false, () => { + expect(renderInlineMarkdown("`trace-id`")).toBe("`trace-id`"); + expect(renderInlineMarkdown("**ERROR**")).toBe("**ERROR**"); + }); + }); + + test("rendered mode: renders code spans", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = stripAnsi(renderInlineMarkdown("`trace-id`")); + expect(result).toContain("trace-id"); + // Should not contain the backtick delimiters + expect(result).not.toContain("`"); + }); + }); + + test("rendered mode: renders bold", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = stripAnsi(renderInlineMarkdown("**ERROR**")); + expect(result).toContain("ERROR"); + }); + }); + + test("rendered mode: does not wrap in paragraph tags", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = renderInlineMarkdown("hello world"); + // parseInline should not add paragraph wrapping + expect(result).not.toContain("

"); + expect(result.trim()).toContain("hello world"); + }); + }); +}); diff --git a/test/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index 52c656e8..291e3621 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -5,7 +5,7 @@ * computeTraceSummary, and formatTraceSummary. */ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { computeTraceSummary, formatTraceDuration, @@ -26,6 +26,38 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, ""); } +/** Force rendered (TTY) mode for a describe block */ +function useRenderedMode() { + let savedPlain: string | undefined; + beforeEach(() => { + savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + }); + afterEach(() => { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + }); +} + +/** Force plain mode for a describe block */ +function usePlainMode() { + let savedPlain: string | undefined; + beforeEach(() => { + savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "1"; + }); + afterEach(() => { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + }); +} + /** * Create a minimal TraceSpan for testing. */ @@ -92,7 +124,9 @@ describe("formatTraceDuration", () => { }); }); -describe("formatTracesHeader", () => { +describe("formatTracesHeader (rendered mode)", () => { + useRenderedMode(); + test("contains column titles", () => { const header = stripAnsi(formatTracesHeader()); expect(header).toContain("TRACE ID"); @@ -107,7 +141,9 @@ describe("formatTracesHeader", () => { }); }); -describe("formatTraceRow", () => { +describe("formatTraceRow (rendered mode)", () => { + useRenderedMode(); + test("includes trace ID", () => { const traceId = "a".repeat(32); const row = formatTraceRow(makeTransaction({ trace: traceId })); @@ -147,6 +183,70 @@ describe("formatTraceRow", () => { }); }); +describe("formatTracesHeader (plain mode)", () => { + usePlainMode(); + + test("emits markdown table header and separator", () => { + const result = formatTracesHeader(); + expect(result).toContain("| Trace ID | Transaction | Duration | When |"); + expect(result).toContain("| --- | --- | ---: | --- |"); + }); + + test("ends with newline", () => { + expect(formatTracesHeader()).toEndWith("\n"); + }); +}); + +describe("formatTraceRow (plain mode)", () => { + usePlainMode(); + + test("emits a markdown table row", () => { + const row = formatTraceRow(makeTransaction()); + expect(row).toMatch(/^\|.+\|.+\|.+\|.+\|\n$/); + }); + + test("includes trace ID", () => { + const traceId = "a".repeat(32); + const row = formatTraceRow(makeTransaction({ trace: traceId })); + expect(row).toContain(traceId); + }); + + test("includes transaction name", () => { + const row = formatTraceRow( + makeTransaction({ transaction: "POST /api/data" }) + ); + expect(row).toContain("POST /api/data"); + }); + + test("includes formatted duration", () => { + const row = formatTraceRow( + makeTransaction({ "transaction.duration": 245 }) + ); + expect(row).toContain("245ms"); + }); + + test("does not truncate long transaction names (no column padding in plain mode)", () => { + const longName = "A".repeat(50); + const row = formatTraceRow(makeTransaction({ transaction: longName })); + expect(row).toContain(longName); + }); + + test("escapes pipe characters in transaction name", () => { + const row = formatTraceRow(makeTransaction({ transaction: "GET /a|b" })); + expect(row).toContain("GET /a\\|b"); + }); + + test("shows 'unknown' for empty transaction", () => { + const row = formatTraceRow(makeTransaction({ transaction: "" })); + expect(row).toContain("unknown"); + }); + + test("ends with newline", () => { + const row = formatTraceRow(makeTransaction()); + expect(row).toEndWith("\n"); + }); +}); + describe("computeTraceSummary", () => { test("computes duration from span timestamps", () => { const spans: TraceSpan[] = [ From 0d179ef42b0a6f8cb1f34622edf2a2d095fbfcf3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 12:54:33 +0000 Subject: [PATCH 06/52] fix(e2e): update org/project view label checks for markdown table format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown table cells use '**Slug**' and '**DSN**' without trailing colons. The E2E runner has no TTY so output is plain markdown — update assertions to match. --- test/e2e/project.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/project.test.ts b/test/e2e/project.test.ts index 8d653dff..357ae890 100644 --- a/test/e2e/project.test.ts +++ b/test/e2e/project.test.ts @@ -153,7 +153,7 @@ describe("sentry org view", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain(TEST_ORG); - expect(result.stdout).toContain("Slug:"); + expect(result.stdout).toContain("Slug"); }, { timeout: 15_000 } ); @@ -232,7 +232,7 @@ describe("sentry project view", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain(TEST_PROJECT); - expect(result.stdout).toContain("Slug:"); + expect(result.stdout).toContain("Slug"); }, { timeout: 15_000 } ); @@ -249,7 +249,7 @@ describe("sentry project view", () => { ]); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("DSN:"); + expect(result.stdout).toContain("DSN"); expect(result.stdout).toContain(TEST_DSN); }, { timeout: 15_000 } From 98f1bb70146e612f41f9a7bc27dfe13ab1792120 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 13:39:05 +0000 Subject: [PATCH 07/52] refactor(formatters): address PR review feedback - Extract LOG_TABLE_COLS and TRACE_TABLE_COLS module-level constants - Add mdTableHeader() and divider() helpers to markdown.ts - Reduce duplication in formatLogRow/formatTraceRow by building markdown cell values once, then branching only on isPlainOutput() - Use middle truncation for log IDs (abc...123 vs abcdef...) - Fix package.json repository URL to use git+ prefix --- package.json | 2 +- src/lib/formatters/log.ts | 42 ++++++++++---------- src/lib/formatters/markdown.ts | 29 ++++++++++++++ src/lib/formatters/trace.ts | 64 ++++++++++++++----------------- test/lib/formatters/log.test.ts | 6 +-- test/lib/formatters/trace.test.ts | 15 ++++---- 6 files changed, 88 insertions(+), 70 deletions(-) diff --git a/package.json b/package.json index 1a0e6d27..bd02c641 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.13.0-dev.0", "repository": { "type": "git", - "url": "https://github.com/getsentry/cli.git" + "url": "git+https://github.com/getsentry/cli.git" }, "devDependencies": { "@biomejs/biome": "2.3.8", diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index e3ce0822..fcb7dd74 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -8,8 +8,10 @@ import type { DetailedSentryLog, SentryLog } from "../../types/index.js"; import { buildTraceUrl } from "../sentry-urls.js"; import { cyan, muted, red, yellow } from "./colors.js"; import { + divider, escapeMarkdownCell, isPlainOutput, + mdTableHeader, renderInlineMarkdown, renderMarkdown, } from "./markdown.js"; @@ -25,6 +27,9 @@ const SEVERITY_COLORS: Record string> = { trace: muted, }; +/** Column headers for the streaming log table */ +const LOG_TABLE_COLS = ["Timestamp", "Level", "Message"] as const; + /** * Format severity level with appropriate color. * Pads to 7 characters for alignment (longest: "warning"). @@ -68,23 +73,17 @@ function formatTimestamp(timestamp: string): string { * @returns Formatted log line with newline */ export function formatLogRow(log: SentryLog): string { + const timestamp = formatTimestamp(log.timestamp); + const level = `**${(log.severity ?? "info").toUpperCase()}**`; + const message = escapeMarkdownCell(log.message ?? ""); + const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; + const cells = [timestamp, level, `${message}${trace}`]; + if (isPlainOutput()) { - const timestamp = formatTimestamp(log.timestamp); - const severity = renderInlineMarkdown( - `**${(log.severity ?? "info").toUpperCase()}**` - ); - const message = escapeMarkdownCell(log.message ?? ""); - const trace = log.trace - ? ` ${renderInlineMarkdown(`\`[${log.trace.slice(0, 8)}]\``)}` - : ""; - return `| ${timestamp} | ${severity} | ${message}${trace} |\n`; + return `| ${cells.join(" | ")} |\n`; } - const timestamp = formatTimestamp(log.timestamp); - const severity = formatSeverity(log.severity); - const message = log.message ?? ""; - const trace = log.trace ? muted(` [${log.trace.slice(0, 8)}]`) : ""; - return `${timestamp} ${severity} ${message}${trace}\n`; + return `| ${cells.map((c) => renderInlineMarkdown(c)).join(" | ")} |\n`; } /** @@ -97,10 +96,12 @@ export function formatLogRow(log: SentryLog): string { */ export function formatLogsHeader(): string { if (isPlainOutput()) { - return "| Timestamp | Level | Message |\n| --- | --- | --- |\n"; + return `${mdTableHeader(LOG_TABLE_COLS)}\n`; } - const header = muted("TIMESTAMP LEVEL MESSAGE"); - return `${header}\n${muted("─".repeat(80))}\n`; + const header = renderInlineMarkdown( + LOG_TABLE_COLS.map((c) => `**${c}**`).join(" ") + ); + return `${header}\n${divider(80)}\n`; } /** @@ -112,12 +113,9 @@ export function formatLogsHeader(): string { * @returns Rendered terminal string with Unicode-bordered table */ export function formatLogTable(logs: SentryLog[]): string { - const header = "| Timestamp | Level | Message |"; - const separator = "| --- | --- | --- |"; const rows = logs .map((log) => { const timestamp = formatTimestamp(log.timestamp); - // Pre-render ANSI severity color — survives the cli-table3 pipeline const severity = formatSeverity(log.severity).trim(); const message = escapeMarkdownCell(log.message ?? ""); const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; @@ -125,7 +123,7 @@ export function formatLogTable(logs: SentryLog[]): string { }) .join("\n"); - return renderMarkdown(`${header}\n${separator}\n${rows}`); + return renderMarkdown(`${mdTableHeader(LOG_TABLE_COLS)}\n${rows}`); } /** @@ -156,7 +154,7 @@ export function formatLogDetails( const logId = log["sentry.item_id"]; const lines: string[] = []; - lines.push(`## Log \`${logId.slice(0, 12)}...\``); + lines.push(`## Log \`${logId.slice(0, 6)}...${logId.slice(-6)}\``); lines.push(""); // Core fields table diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 9a29c5ff..81d3dcb6 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -23,6 +23,7 @@ import chalk from "chalk"; import { type MarkedExtension, marked } from "marked"; import { markedTerminal as _markedTerminal } from "marked-terminal"; +import { muted } from "./colors.js"; // @types/marked-terminal@6 describes the legacy class-based API; the package's // actual markedTerminal() returns a {renderer, useNewRenderer} MarkedExtension @@ -127,6 +128,34 @@ export function escapeMarkdownCell(value: string): string { return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); } +/** + * Build a markdown table header row + separator from column definitions. + * + * @param cols - Column definitions: `[name]` or `[name, "right"]` for right-align + * @returns Two-line string: `| A | B |\n| --- | ---: |` + */ +export function mdTableHeader( + cols: ReadonlyArray +): string { + const names = cols.map((c) => (typeof c === "string" ? c : c[0])); + const seps = cols.map((c) => + typeof c !== "string" && c[1] === "right" ? "---:" : "---" + ); + return `| ${names.join(" | ")} |\n| ${seps.join(" | ")} |`; +} + +/** + * Render a muted horizontal rule for streaming header separators. + * + * Centralises the divider character so all headers share a single style. + * + * @param width - Number of characters (defaults to 80) + * @returns Muted string of box-drawing dashes + */ +export function divider(width = 80): string { + return muted("\u2500".repeat(width)); +} + /** * Render a full markdown document as styled terminal output, or return the * raw CommonMark string when in plain mode. diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 0a944d96..6bd1d33c 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -5,11 +5,12 @@ */ import type { TraceSpan, TransactionListItem } from "../../types/index.js"; -import { muted } from "./colors.js"; import { formatRelativeTime } from "./human.js"; import { + divider, escapeMarkdownCell, isPlainOutput, + mdTableHeader, renderInlineMarkdown, renderMarkdown, } from "./markdown.js"; @@ -46,6 +47,14 @@ export function formatTraceDuration(ms: number): string { return `${mins}m ${secs}s`; } +/** Column headers for the streaming trace table (Duration is right-aligned) */ +const TRACE_TABLE_COLS = [ + "Trace ID", + "Transaction", + ["Duration", "right"] as const, + "When", +] as const; + /** * Format column header for traces list (used before per-row output). * @@ -56,23 +65,16 @@ export function formatTraceDuration(ms: number): string { */ export function formatTracesHeader(): string { if (isPlainOutput()) { - return "| Trace ID | Transaction | Duration | When |\n| --- | --- | ---: | --- |\n"; + return `${mdTableHeader(TRACE_TABLE_COLS)}\n`; } - const header = muted( - "TRACE ID TRANSACTION DURATION WHEN" + const header = renderInlineMarkdown( + TRACE_TABLE_COLS.map((c) => `**${typeof c === "string" ? c : c[0]}**`).join( + " " + ) ); - return `${header}\n${muted("─".repeat(96))}\n`; + return `${header}\n${divider(96)}\n`; } -/** Maximum transaction name length before truncation */ -const MAX_TRANSACTION_LENGTH = 30; - -/** Column width for trace ID display */ -const TRACE_ID_WIDTH = 32; - -/** Column width for duration display */ -const DURATION_WIDTH = 10; - /** * Format a single transaction row for the traces list. * @@ -84,23 +86,17 @@ const DURATION_WIDTH = 10; * @returns Formatted row string with newline */ export function formatTraceRow(item: TransactionListItem): string { + const traceId = `\`${item.trace}\``; + const transaction = escapeMarkdownCell(item.transaction || "unknown"); + const duration = formatTraceDuration(item["transaction.duration"]); + const when = formatRelativeTime(item.timestamp).trim(); + const cells = [traceId, transaction, duration, when]; + if (isPlainOutput()) { - const traceId = renderInlineMarkdown(`\`${item.trace}\``); - const transaction = escapeMarkdownCell(item.transaction || "unknown"); - const duration = formatTraceDuration(item["transaction.duration"]); - const when = formatRelativeTime(item.timestamp).trim(); - return `| ${traceId} | ${transaction} | ${duration} | ${when} |\n`; + return `| ${cells.join(" | ")} |\n`; } - const traceId = item.trace.slice(0, TRACE_ID_WIDTH).padEnd(TRACE_ID_WIDTH); - const transaction = (item.transaction || "unknown") - .slice(0, MAX_TRANSACTION_LENGTH) - .padEnd(MAX_TRANSACTION_LENGTH); - const duration = formatTraceDuration(item["transaction.duration"]).padStart( - DURATION_WIDTH - ); - const when = formatRelativeTime(item.timestamp); - return `${traceId} ${transaction} ${duration} ${when}\n`; + return `| ${cells.map((c) => renderInlineMarkdown(c)).join(" | ")} |\n`; } /** @@ -112,21 +108,17 @@ export function formatTraceRow(item: TransactionListItem): string { * @returns Rendered terminal string with Unicode-bordered table */ export function formatTraceTable(items: TransactionListItem[]): string { - const header = "| Trace ID | Transaction | Duration | When |"; - const separator = "| --- | --- | ---: | --- |"; const rows = items .map((item) => { - const traceId = item.trace; - const transaction = item.transaction || "unknown"; + const traceId = `\`${item.trace}\``; + const transaction = escapeMarkdownCell(item.transaction || "unknown"); const duration = formatTraceDuration(item["transaction.duration"]); const when = formatRelativeTime(item.timestamp).trim(); - // Escape special markdown characters in cell values to avoid breaking the table - const safeTransaction = escapeMarkdownCell(transaction); - return `| \`${traceId}\` | ${safeTransaction} | ${duration} | ${when} |`; + return `| ${traceId} | ${transaction} | ${duration} | ${when} |`; }) .join("\n"); - return renderMarkdown(`${header}\n${separator}\n${rows}`); + return renderMarkdown(`${mdTableHeader(TRACE_TABLE_COLS)}\n${rows}`); } /** Trace summary computed from a span tree */ diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index 97fd961e..9b3f8f3c 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -156,9 +156,9 @@ describe("formatLogsHeader (rendered mode)", () => { test("contains column titles", () => { const result = stripAnsi(formatLogsHeader()); - expect(result).toContain("TIMESTAMP"); - expect(result).toContain("LEVEL"); - expect(result).toContain("MESSAGE"); + expect(result).toContain("Timestamp"); + expect(result).toContain("Level"); + expect(result).toContain("Message"); }); test("contains divider line", () => { diff --git a/test/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index 291e3621..4a6dfa6f 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -129,10 +129,10 @@ describe("formatTracesHeader (rendered mode)", () => { test("contains column titles", () => { const header = stripAnsi(formatTracesHeader()); - expect(header).toContain("TRACE ID"); - expect(header).toContain("TRANSACTION"); - expect(header).toContain("DURATION"); - expect(header).toContain("WHEN"); + expect(header).toContain("Trace ID"); + expect(header).toContain("Transaction"); + expect(header).toContain("Duration"); + expect(header).toContain("When"); }); test("ends with newline", () => { @@ -164,12 +164,11 @@ describe("formatTraceRow (rendered mode)", () => { expect(row).toContain("245ms"); }); - test("truncates long transaction names", () => { + test("includes full transaction name in markdown row", () => { const longName = "A".repeat(50); const row = formatTraceRow(makeTransaction({ transaction: longName })); - // Should be truncated to 30 chars - expect(row).not.toContain(longName); - expect(row).toContain("A".repeat(30)); + // Full name preserved in markdown table cell + expect(row).toContain(longName); }); test("shows 'unknown' for empty transaction", () => { From 7f55c535e280d19ca17c1902c706bb46ac9751db Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 14:28:26 +0000 Subject: [PATCH 08/52] refactor(formatters): extract mdRow/mdKvTable helpers, simplify column defs - Add mdRow() helper: shared streaming row renderer used by both formatLogRow and formatTraceRow (eliminates duplicated isPlainOutput branching) - Add mdKvTable() helper: builds key-value detail tables from [label, value] tuples (replaces manual string concatenation in formatLogDetails sections) - Simplify mdTableHeader() column alignment: use ':' suffix convention (e.g. 'Duration:') instead of [name, 'right'] tuples - Make formatLogsHeader/formatTracesHeader consistent: both modes now emit markdown table rows via mdRow() instead of diverging formats --- src/lib/formatters/log.ts | 104 ++++++++++-------------------- src/lib/formatters/markdown.ts | 64 +++++++++++++++--- src/lib/formatters/trace.ts | 31 ++------- test/lib/formatters/log.test.ts | 3 +- test/lib/formatters/trace.test.ts | 5 +- 5 files changed, 101 insertions(+), 106 deletions(-) diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index fcb7dd74..c495fafd 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -10,9 +10,9 @@ import { cyan, muted, red, yellow } from "./colors.js"; import { divider, escapeMarkdownCell, - isPlainOutput, + mdKvTable, + mdRow, mdTableHeader, - renderInlineMarkdown, renderMarkdown, } from "./markdown.js"; @@ -77,13 +77,7 @@ export function formatLogRow(log: SentryLog): string { const level = `**${(log.severity ?? "info").toUpperCase()}**`; const message = escapeMarkdownCell(log.message ?? ""); const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; - const cells = [timestamp, level, `${message}${trace}`]; - - if (isPlainOutput()) { - return `| ${cells.join(" | ")} |\n`; - } - - return `| ${cells.map((c) => renderInlineMarkdown(c)).join(" | ")} |\n`; + return mdRow([timestamp, level, `${message}${trace}`]); } /** @@ -95,13 +89,7 @@ export function formatLogRow(log: SentryLog): string { * @returns Header string (includes trailing newline) */ export function formatLogsHeader(): string { - if (isPlainOutput()) { - return `${mdTableHeader(LOG_TABLE_COLS)}\n`; - } - const header = renderInlineMarkdown( - LOG_TABLE_COLS.map((c) => `**${c}**`).join(" ") - ); - return `${header}\n${divider(80)}\n`; + return `${mdRow(LOG_TABLE_COLS.map((c) => `**${c}**`))}${divider(80)}\n`; } /** @@ -158,14 +146,13 @@ export function formatLogDetails( lines.push(""); // Core fields table - const rows: string[] = []; - rows.push(`| **ID** | \`${logId}\` |`); - rows.push(`| **Timestamp** | ${formatTimestamp(log.timestamp)} |`); - rows.push(`| **Severity** | ${formatSeverityLabel(log.severity)} |`); - - lines.push("| | |"); - lines.push("|---|---|"); - lines.push(...rows); + lines.push( + mdKvTable([ + ["ID", `\`${logId}\``], + ["Timestamp", formatTimestamp(log.timestamp)], + ["Severity", formatSeverityLabel(log.severity)], + ]) + ); if (log.message) { lines.push(""); @@ -176,55 +163,42 @@ export function formatLogDetails( // Context section if (log.project || log.environment || log.release) { - lines.push(""); - lines.push("### Context"); - lines.push(""); - const ctxRows: string[] = []; + const ctxRows: [string, string][] = []; if (log.project) { - ctxRows.push(`| **Project** | ${log.project} |`); + ctxRows.push(["Project", log.project]); } if (log.environment) { - ctxRows.push(`| **Environment** | ${log.environment} |`); + ctxRows.push(["Environment", log.environment]); } if (log.release) { - ctxRows.push(`| **Release** | ${log.release} |`); + ctxRows.push(["Release", log.release]); } - lines.push("| | |"); - lines.push("|---|---|"); - lines.push(...ctxRows); + lines.push(""); + lines.push(mdKvTable(ctxRows, "Context")); } // SDK section const sdkName = log["sdk.name"]; const sdkVersion = log["sdk.version"]; if (sdkName || sdkVersion) { - lines.push(""); - lines.push("### SDK"); - lines.push(""); // Wrap in backticks to prevent markdown from interpreting underscores/dashes const sdkInfo = sdkName && sdkVersion ? `\`${sdkName} ${sdkVersion}\`` : `\`${sdkName ?? sdkVersion}\``; - lines.push("| | |"); - lines.push("|---|---|"); - lines.push(`| **SDK** | ${sdkInfo} |`); + lines.push(""); + lines.push(mdKvTable([["SDK", sdkInfo]], "SDK")); } // Trace section if (log.trace) { - lines.push(""); - lines.push("### Trace"); - lines.push(""); - const traceRows: string[] = []; - traceRows.push(`| **Trace ID** | \`${log.trace}\` |`); + const traceRows: [string, string][] = [["Trace ID", `\`${log.trace}\``]]; if (log.span_id) { - traceRows.push(`| **Span ID** | \`${log.span_id}\` |`); + traceRows.push(["Span ID", `\`${log.span_id}\``]); } - traceRows.push(`| **Link** | ${buildTraceUrl(orgSlug, log.trace)} |`); - lines.push("| | |"); - lines.push("|---|---|"); - lines.push(...traceRows); + traceRows.push(["Link", buildTraceUrl(orgSlug, log.trace)]); + lines.push(""); + lines.push(mdKvTable(traceRows, "Trace")); } // Source location section (OTel code attributes) @@ -232,22 +206,18 @@ export function formatLogDetails( const codeFilePath = log["code.file.path"]; const codeLineNumber = log["code.line.number"]; if (codeFunction || codeFilePath) { - lines.push(""); - lines.push("### Source Location"); - lines.push(""); - const srcRows: string[] = []; + const srcRows: [string, string][] = []; if (codeFunction) { - srcRows.push(`| **Function** | \`${codeFunction}\` |`); + srcRows.push(["Function", `\`${codeFunction}\``]); } if (codeFilePath) { const location = codeLineNumber ? `${codeFilePath}:${codeLineNumber}` : codeFilePath; - srcRows.push(`| **File** | \`${location}\` |`); + srcRows.push(["File", `\`${location}\``]); } - lines.push("| | |"); - lines.push("|---|---|"); - lines.push(...srcRows); + lines.push(""); + lines.push(mdKvTable(srcRows, "Source Location")); } // OpenTelemetry section @@ -255,22 +225,18 @@ export function formatLogDetails( const otelStatus = log["sentry.otel.status_code"]; const otelScope = log["sentry.otel.instrumentation_scope.name"]; if (otelKind || otelStatus || otelScope) { - lines.push(""); - lines.push("### OpenTelemetry"); - lines.push(""); - const otelRows: string[] = []; + const otelRows: [string, string][] = []; if (otelKind) { - otelRows.push(`| **Kind** | ${otelKind} |`); + otelRows.push(["Kind", otelKind]); } if (otelStatus) { - otelRows.push(`| **Status** | ${otelStatus} |`); + otelRows.push(["Status", otelStatus]); } if (otelScope) { - otelRows.push(`| **Scope** | ${otelScope} |`); + otelRows.push(["Scope", otelScope]); } - lines.push("| | |"); - lines.push("|---|---|"); - lines.push(...otelRows); + lines.push(""); + lines.push(mdKvTable(otelRows, "OpenTelemetry")); } return renderMarkdown(lines.join("\n")); diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 81d3dcb6..2b8e33f2 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -129,21 +129,67 @@ export function escapeMarkdownCell(value: string): string { } /** - * Build a markdown table header row + separator from column definitions. + * Build a raw markdown table header row + separator from column names. * - * @param cols - Column definitions: `[name]` or `[name, "right"]` for right-align + * Column names ending with `:` are right-aligned (the `:` is stripped from + * the displayed name and a `---:` separator is emitted instead of `---`). + * + * Used by batch-rendered tables that pipe the result through `renderMarkdown()`. + * For streaming table rows use {@link mdRow}. + * + * @param cols - Column names (append `:` for right-align, e.g. `"Duration:"`) * @returns Two-line string: `| A | B |\n| --- | ---: |` */ -export function mdTableHeader( - cols: ReadonlyArray -): string { - const names = cols.map((c) => (typeof c === "string" ? c : c[0])); - const seps = cols.map((c) => - typeof c !== "string" && c[1] === "right" ? "---:" : "---" - ); +export function mdTableHeader(cols: readonly string[]): string { + const names = cols.map((c) => (c.endsWith(":") ? c.slice(0, -1) : c)); + const seps = cols.map((c) => (c.endsWith(":") ? "---:" : "---")); return `| ${names.join(" | ")} |\n| ${seps.join(" | ")} |`; } +/** + * Build a markdown table row from cell values. + * + * In plain mode the cells are emitted as-is (raw CommonMark). + * In rendered mode each cell is passed through `renderInlineMarkdown()` + * so inline constructs like `**bold**` and `` `code` `` become ANSI-styled. + * + * @param cells - Cell values (may contain inline markdown) + * @returns `| a | b |\n` + */ +export function mdRow(cells: readonly string[]): string { + const out = isPlainOutput() + ? cells + : cells.map((c) => renderInlineMarkdown(c)); + return `| ${out.join(" | ")} |\n`; +} + +/** + * Build a key-value markdown table section with an optional heading. + * + * Each entry is rendered as `| **Label** | value |`. + * Uses the blank-header-row pattern required by marked-terminal. + * + * @param rows - `[label, value]` tuples + * @param heading - Optional `### Heading` text (omit the `###` prefix) + * @returns Raw markdown string (not rendered) + */ +export function mdKvTable( + rows: ReadonlyArray, + heading?: string +): string { + const lines: string[] = []; + if (heading) { + lines.push(`### ${heading}`); + lines.push(""); + } + lines.push("| | |"); + lines.push("|---|---|"); + for (const [label, value] of rows) { + lines.push(`| **${label}** | ${value} |`); + } + return lines.join("\n"); +} + /** * Render a muted horizontal rule for streaming header separators. * diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 6bd1d33c..690ad03c 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -9,9 +9,8 @@ import { formatRelativeTime } from "./human.js"; import { divider, escapeMarkdownCell, - isPlainOutput, + mdRow, mdTableHeader, - renderInlineMarkdown, renderMarkdown, } from "./markdown.js"; @@ -47,13 +46,8 @@ export function formatTraceDuration(ms: number): string { return `${mins}m ${secs}s`; } -/** Column headers for the streaming trace table (Duration is right-aligned) */ -const TRACE_TABLE_COLS = [ - "Trace ID", - "Transaction", - ["Duration", "right"] as const, - "When", -] as const; +/** Column headers for the streaming trace table (`:` suffix = right-aligned) */ +const TRACE_TABLE_COLS = ["Trace ID", "Transaction", "Duration:", "When"]; /** * Format column header for traces list (used before per-row output). @@ -64,15 +58,10 @@ const TRACE_TABLE_COLS = [ * @returns Header string (includes trailing newline) */ export function formatTracesHeader(): string { - if (isPlainOutput()) { - return `${mdTableHeader(TRACE_TABLE_COLS)}\n`; - } - const header = renderInlineMarkdown( - TRACE_TABLE_COLS.map((c) => `**${typeof c === "string" ? c : c[0]}**`).join( - " " - ) + const names = TRACE_TABLE_COLS.map((c) => + c.endsWith(":") ? c.slice(0, -1) : c ); - return `${header}\n${divider(96)}\n`; + return `${mdRow(names.map((n) => `**${n}**`))}${divider(96)}\n`; } /** @@ -90,13 +79,7 @@ export function formatTraceRow(item: TransactionListItem): string { const transaction = escapeMarkdownCell(item.transaction || "unknown"); const duration = formatTraceDuration(item["transaction.duration"]); const when = formatRelativeTime(item.timestamp).trim(); - const cells = [traceId, transaction, duration, when]; - - if (isPlainOutput()) { - return `| ${cells.join(" | ")} |\n`; - } - - return `| ${cells.map((c) => renderInlineMarkdown(c)).join(" | ")} |\n`; + return mdRow([traceId, transaction, duration, when]); } /** diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index 9b3f8f3c..06e933a1 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -224,8 +224,7 @@ describe("formatLogsHeader (plain mode)", () => { test("emits markdown table header and separator", () => { const result = formatLogsHeader(); - expect(result).toContain("| Timestamp | Level | Message |"); - expect(result).toContain("| --- | --- | --- |"); + expect(result).toContain("| **Timestamp** | **Level** | **Message** |"); }); test("ends with newline", () => { diff --git a/test/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index 4a6dfa6f..3255a288 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -187,8 +187,9 @@ describe("formatTracesHeader (plain mode)", () => { test("emits markdown table header and separator", () => { const result = formatTracesHeader(); - expect(result).toContain("| Trace ID | Transaction | Duration | When |"); - expect(result).toContain("| --- | --- | ---: | --- |"); + expect(result).toContain( + "| **Trace ID** | **Transaction** | **Duration** | **When** |" + ); }); test("ends with newline", () => { From 5412424e3d21f3f9104a212b7f7a17a68af26e5c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 14:47:48 +0000 Subject: [PATCH 09/52] test(formatters): add coverage for formatEventDetails, formatSolution, table helpers 57 new tests covering: - formatEventDetails with all conditional sections: stack traces, breadcrumbs, request, user/geo, environment, replay, tags, SDK - formatSolution with steps, empty steps, markdown in descriptions - formatLogTable and formatTraceTable batch rendering - mdRow, mdKvTable, mdTableHeader, escapeMarkdownCell helpers --- test/lib/formatters/human.details.test.ts | 407 ++++++++++++++++++++++ test/lib/formatters/log.test.ts | 37 ++ test/lib/formatters/markdown.test.ts | 122 +++++++ test/lib/formatters/seer.test.ts | 83 ++++- test/lib/formatters/trace.test.ts | 43 ++- 5 files changed, 690 insertions(+), 2 deletions(-) diff --git a/test/lib/formatters/human.details.test.ts b/test/lib/formatters/human.details.test.ts index 4e0aba64..cdbb9452 100644 --- a/test/lib/formatters/human.details.test.ts +++ b/test/lib/formatters/human.details.test.ts @@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test"; import { calculateOrgSlugWidth, calculateProjectColumnWidths, + formatEventDetails, formatFixability, formatFixabilityDetail, formatIssueDetails, @@ -19,6 +20,7 @@ import { getSeerFixabilityLabel, } from "../../../src/lib/formatters/human.js"; import type { + SentryEvent, SentryIssue, SentryOrganization, SentryProject, @@ -605,3 +607,408 @@ describe("formatFixabilityDetail", () => { expect(formatFixabilityDetail(undefined)).toBe(""); }); }); + +// Event Formatting Tests + +function createMockEvent(overrides: Partial = {}): SentryEvent { + return { + eventID: "abc123def456abc7890", + dateReceived: "2024-01-15T12:30:00Z", + ...overrides, + }; +} + +describe("formatEventDetails", () => { + test("returns a string", () => { + const result = formatEventDetails(createMockEvent()); + expect(typeof result).toBe("string"); + }); + + test("includes event ID in header", () => { + const result = stripAnsi(formatEventDetails(createMockEvent())); + expect(result).toContain("abc123de"); + }); + + test("includes custom header text", () => { + const result = stripAnsi( + formatEventDetails(createMockEvent(), "My Custom Header") + ); + expect(result).toContain("My Custom Header"); + }); + + test("includes event ID and received date", () => { + const result = stripAnsi(formatEventDetails(createMockEvent())); + expect(result).toContain("abc123def456abc7890"); + expect(result).toContain("Event ID"); + expect(result).toContain("Received"); + }); + + test("includes location when present", () => { + const result = stripAnsi( + formatEventDetails(createMockEvent({ location: "app/main.py" })) + ); + expect(result).toContain("Location"); + expect(result).toContain("app/main.py"); + }); + + test("includes trace context when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + contexts: { trace: { trace_id: "aabbccdd11223344" } }, + }) + ) + ); + expect(result).toContain("Trace"); + expect(result).toContain("aabbccdd11223344"); + }); + + test("includes SDK info when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + sdk: { name: "sentry.javascript.browser", version: "7.0.0" }, + }) + ) + ); + expect(result).toContain("SDK"); + expect(result).toContain("sentry.javascript.browser"); + expect(result).toContain("7.0.0"); + }); + + test("includes release when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + release: { version: "1.0.0", shortVersion: "1.0.0" }, + }) + ) + ); + expect(result).toContain("Release"); + expect(result).toContain("1.0.0"); + }); + + test("includes user section when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + user: { + email: "test@example.com", + username: "testuser", + id: "42", + ip_address: "192.168.1.1", + }, + }) + ) + ); + expect(result).toContain("User"); + expect(result).toContain("test@example.com"); + expect(result).toContain("testuser"); + expect(result).toContain("192.168.1.1"); + }); + + test("includes user geo when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + user: { + email: "test@example.com", + geo: { city: "Berlin", region: "Berlin", country_code: "DE" }, + }, + }) + ) + ); + expect(result).toContain("Location"); + expect(result).toContain("Berlin"); + expect(result).toContain("DE"); + }); + + test("includes environment contexts when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + contexts: { + browser: { name: "Chrome", version: "120.0" }, + os: { name: "Windows", version: "11" }, + device: { family: "Desktop", brand: "Apple" }, + }, + }) + ) + ); + expect(result).toContain("Environment"); + expect(result).toContain("Chrome"); + expect(result).toContain("Windows"); + expect(result).toContain("Desktop"); + }); + + test("includes request section when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "request", + data: { + url: "https://api.example.com/users", + method: "POST", + headers: [["User-Agent", "Mozilla/5.0"]], + }, + }, + ], + }) + ) + ); + expect(result).toContain("Request"); + expect(result).toContain("POST https://api.example.com/users"); + expect(result).toContain("Mozilla/5.0"); + }); + + test("includes stack trace when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "exception", + data: { + values: [ + { + type: "TypeError", + value: "Cannot read property", + mechanism: { type: "generic", handled: false }, + stacktrace: { + frames: [ + { + function: "handleClick", + filename: "app.js", + lineNo: 42, + colNo: 10, + inApp: true, + }, + ], + }, + }, + ], + }, + }, + ], + }) + ) + ); + expect(result).toContain("Stack Trace"); + expect(result).toContain("TypeError: Cannot read property"); + expect(result).toContain("handleClick"); + expect(result).toContain("app.js"); + expect(result).toContain("[in-app]"); + expect(result).toContain("unhandled"); + }); + + test("includes stack frame code context when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "exception", + data: { + values: [ + { + type: "Error", + value: "fail", + stacktrace: { + frames: [ + { + function: "foo", + filename: "bar.js", + lineNo: 10, + colNo: 1, + context: [ + [9, " const x = 1;"], + [10, ' throw new Error("fail");'], + [11, "}"], + ], + }, + ], + }, + }, + ], + }, + }, + ], + }) + ) + ); + expect(result).toContain("const x = 1"); + expect(result).toContain("throw new Error"); + }); + + test("includes breadcrumbs when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "breadcrumbs", + data: { + values: [ + { + timestamp: "2024-01-15T12:29:55Z", + level: "info", + category: "navigation", + message: "User clicked button", + }, + { + timestamp: "2024-01-15T12:30:00Z", + level: "error", + category: "http", + data: { + url: "https://api.example.com/data", + method: "GET", + status_code: 500, + }, + }, + ], + }, + }, + ], + }) + ) + ); + expect(result).toContain("Breadcrumbs"); + expect(result).toContain("navigation"); + expect(result).toContain("User clicked button"); + expect(result).toContain("GET"); + expect(result).toContain("500"); + }); + + test("includes replay link when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + tags: [{ key: "replayId", value: "replay-uuid-123" }], + }), + "Latest Event", + "https://acme.sentry.io/issues/789/" + ) + ); + expect(result).toContain("Replay"); + expect(result).toContain("replay-uuid-123"); + expect(result).toContain("https://acme.sentry.io/replays/replay-uuid-123/"); + }); + + test("includes tags when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + tags: [ + { key: "browser", value: "Chrome 120" }, + { key: "os", value: "Windows 11" }, + ], + }) + ) + ); + expect(result).toContain("Tags"); + expect(result).toContain("browser"); + expect(result).toContain("Chrome 120"); + }); + + test("handles minimal event", () => { + const result = formatEventDetails( + createMockEvent({ dateReceived: undefined }) + ); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + test("handles breadcrumb navigation data", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "breadcrumbs", + data: { + values: [ + { + category: "navigation", + data: { from: "/home", to: "/profile" }, + }, + ], + }, + }, + ], + }) + ) + ); + expect(result).toContain("/home"); + expect(result).toContain("/profile"); + }); + + test("truncates long breadcrumb messages", () => { + const longMsg = "X".repeat(100); + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "breadcrumbs", + data: { + values: [ + { category: "console", message: longMsg, level: "info" }, + ], + }, + }, + ], + }) + ) + ); + expect(result).not.toContain(longMsg); + expect(result).toContain("..."); + }); + + test("skips empty breadcrumbs array", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [{ type: "breadcrumbs", data: { values: [] } }], + }) + ) + ); + expect(result).not.toContain("Breadcrumbs"); + }); + + test("shows user name when available", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + user: { name: "John Doe" }, + }) + ) + ); + expect(result).toContain("Name"); + expect(result).toContain("John Doe"); + }); + + test("skips user section when user has no data", () => { + const result = stripAnsi(formatEventDetails(createMockEvent({ user: {} }))); + expect(result).not.toContain("User"); + }); + + test("skips environment section when no contexts", () => { + const result = stripAnsi( + formatEventDetails(createMockEvent({ contexts: null })) + ); + expect(result).not.toContain("Environment"); + }); + + test("skips request section when no URL", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [{ type: "request", data: {} }], + }) + ) + ); + expect(result).not.toContain("Request"); + }); +}); diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index 06e933a1..9cb68b78 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -7,6 +7,7 @@ import { formatLogDetails, formatLogRow, formatLogsHeader, + formatLogTable, } from "../../../src/lib/formatters/log.js"; import type { DetailedSentryLog, SentryLog } from "../../../src/types/index.js"; @@ -387,3 +388,39 @@ describe("formatLogDetails", () => { expect(result).not.toContain("Trace"); }); }); + +describe("formatLogTable", () => { + test("returns a string", () => { + const result = formatLogTable([createTestLog()]); + expect(typeof result).toBe("string"); + }); + + test("includes all log messages", () => { + const logs = [ + createTestLog({ message: "First log" }), + createTestLog({ message: "Second log" }), + ]; + const result = stripAnsi(formatLogTable(logs)); + expect(result).toContain("First log"); + expect(result).toContain("Second log"); + }); + + test("includes severity levels", () => { + const result = stripAnsi( + formatLogTable([createTestLog({ severity: "error" })]) + ); + expect(result).toContain("ERROR"); + }); + + test("includes trace IDs when present", () => { + const result = stripAnsi( + formatLogTable([createTestLog({ trace: "abcdef1234567890" })]) + ); + expect(result).toContain("abcdef12"); + }); + + test("handles empty messages", () => { + const result = formatLogTable([createTestLog({ message: "" })]); + expect(typeof result).toBe("string"); + }); +}); diff --git a/test/lib/formatters/markdown.test.ts b/test/lib/formatters/markdown.test.ts index e39abf5f..7158ff80 100644 --- a/test/lib/formatters/markdown.test.ts +++ b/test/lib/formatters/markdown.test.ts @@ -8,7 +8,11 @@ import { describe, expect, test } from "bun:test"; import { + escapeMarkdownCell, isPlainOutput, + mdKvTable, + mdRow, + mdTableHeader, renderInlineMarkdown, renderMarkdown, } from "../../../src/lib/formatters/markdown.js"; @@ -272,3 +276,121 @@ describe("renderInlineMarkdown", () => { }); }); }); + +// --------------------------------------------------------------------------- +// escapeMarkdownCell +// --------------------------------------------------------------------------- + +describe("escapeMarkdownCell", () => { + test("escapes pipe characters", () => { + expect(escapeMarkdownCell("foo|bar")).toBe("foo\\|bar"); + }); + + test("escapes backslashes before pipes", () => { + expect(escapeMarkdownCell("a\\|b")).toBe("a\\\\\\|b"); + }); + + test("returns unchanged string when no special chars", () => { + expect(escapeMarkdownCell("hello world")).toBe("hello world"); + }); + + test("handles empty string", () => { + expect(escapeMarkdownCell("")).toBe(""); + }); + + test("handles multiple pipes", () => { + const result = escapeMarkdownCell("a|b|c"); + expect(result).toBe("a\\|b\\|c"); + }); +}); + +// --------------------------------------------------------------------------- +// mdTableHeader +// --------------------------------------------------------------------------- + +describe("mdTableHeader", () => { + test("generates header and separator rows", () => { + const result = mdTableHeader(["Name", "Value"]); + expect(result).toBe("| Name | Value |\n| --- | --- |"); + }); + + test("right-aligns columns with : suffix", () => { + const result = mdTableHeader(["Label", "Count:"]); + expect(result).toBe("| Label | Count |\n| --- | ---: |"); + }); + + test("strips : suffix from display name", () => { + const result = mdTableHeader(["Duration:"]); + expect(result).toContain("| Duration |"); + expect(result).not.toContain("Duration:"); + }); + + test("handles single column", () => { + const result = mdTableHeader(["Only"]); + expect(result).toBe("| Only |\n| --- |"); + }); +}); + +// --------------------------------------------------------------------------- +// mdRow +// --------------------------------------------------------------------------- + +describe("mdRow", () => { + test("plain mode: returns raw markdown cells", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, true, () => { + const result = mdRow(["**bold**", "`code`"]); + expect(result).toBe("| **bold** | `code` |\n"); + }); + }); + + test("rendered mode: applies inline rendering", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = mdRow(["**bold**", "plain"]); + // Should contain ANSI codes for bold + expect(result).not.toBe("| **bold** | plain |\n"); + expect(stripAnsi(result)).toContain("bold"); + expect(stripAnsi(result)).toContain("plain"); + }); + }); + + test("produces pipe-delimited format", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, true, () => { + const result = mdRow(["a", "b", "c"]); + expect(result).toBe("| a | b | c |\n"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// mdKvTable +// --------------------------------------------------------------------------- + +describe("mdKvTable", () => { + test("generates key-value table rows", () => { + const result = mdKvTable([ + ["Name", "Alice"], + ["Age", "30"], + ]); + expect(result).toContain("| | |"); + expect(result).toContain("|---|---|"); + expect(result).toContain("| **Name** | Alice |"); + expect(result).toContain("| **Age** | 30 |"); + }); + + test("includes heading when provided", () => { + const result = mdKvTable([["Key", "Val"]], "Details"); + expect(result).toContain("### Details"); + expect(result).toContain("| **Key** | Val |"); + }); + + test("omits heading when not provided", () => { + const result = mdKvTable([["K", "V"]]); + expect(result).not.toContain("###"); + expect(result).toContain("| **K** | V |"); + }); + + test("handles single row", () => { + const result = mdKvTable([["Only", "Row"]]); + expect(result).toContain("| **Only** | Row |"); + }); +}); diff --git a/test/lib/formatters/seer.test.ts b/test/lib/formatters/seer.test.ts index 492562e2..6989ac23 100644 --- a/test/lib/formatters/seer.test.ts +++ b/test/lib/formatters/seer.test.ts @@ -11,12 +11,17 @@ import { formatAutofixError, formatProgressLine, formatRootCauseList, + formatSolution, getProgressMessage, getSpinnerFrame, handleSeerApiError, truncateProgressMessage, } from "../../../src/lib/formatters/seer.js"; -import type { AutofixState, RootCause } from "../../../src/types/seer.js"; +import type { + AutofixState, + RootCause, + SolutionArtifact, +} from "../../../src/types/seer.js"; /** Strip ANSI escape codes */ function stripAnsi(str: string): string { @@ -319,3 +324,79 @@ describe("SeerError formatting", () => { expect(formatted).not.toContain("/seer/"); }); }); + +describe("formatSolution", () => { + function makeSolution( + overrides: Partial = {} + ): SolutionArtifact { + return { + key: "solution", + data: { + one_line_summary: "Add null check before accessing user.name", + steps: [ + { + title: "Update the handler function", + description: "Check for null before accessing the property.", + }, + ], + ...overrides, + }, + }; + } + + test("returns a string", () => { + const result = formatSolution(makeSolution()); + expect(typeof result).toBe("string"); + }); + + test("includes summary text", () => { + const result = stripAnsi(formatSolution(makeSolution())); + expect(result).toContain("Add null check before accessing user.name"); + }); + + test("includes Solution heading", () => { + const result = stripAnsi(formatSolution(makeSolution())); + expect(result).toContain("Solution"); + }); + + test("includes step titles", () => { + const result = stripAnsi( + formatSolution( + makeSolution({ + steps: [ + { title: "Step One", description: "Do the first thing." }, + { title: "Step Two", description: "Do the second thing." }, + ], + }) + ) + ); + expect(result).toContain("Step One"); + expect(result).toContain("Step Two"); + expect(result).toContain("Do the first thing"); + expect(result).toContain("Do the second thing"); + }); + + test("handles empty steps array", () => { + const result = stripAnsi(formatSolution(makeSolution({ steps: [] }))); + expect(result).toContain("Solution"); + expect(result).toContain("Add null check"); + expect(result).not.toContain("Steps to implement"); + }); + + test("preserves markdown in step descriptions", () => { + const result = stripAnsi( + formatSolution( + makeSolution({ + steps: [ + { + title: "Fix code", + description: "Change `foo()` to `bar()`\nThen redeploy.", + }, + ], + }) + ) + ); + expect(result).toContain("Fix code"); + expect(result).toContain("foo()"); + }); +}); diff --git a/test/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index 3255a288..5a252ba4 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -1,7 +1,8 @@ /** * Unit Tests for Trace Formatters * - * Tests for formatTraceDuration, formatTracesHeader, formatTraceRow, + * Tests for formatTraceDuration, formatTraceTable, + formatTracesHeader, formatTraceRow, * computeTraceSummary, and formatTraceSummary. */ @@ -12,6 +13,7 @@ import { formatTraceRow, formatTraceSummary, formatTracesHeader, + formatTraceTable, } from "../../../src/lib/formatters/trace.js"; import type { TraceSpan, @@ -421,3 +423,42 @@ describe("formatTraceSummary", () => { expect(output).not.toContain("Started"); }); }); + +describe("formatTraceTable", () => { + test("returns a string", () => { + const result = formatTraceTable([makeTransaction()]); + expect(typeof result).toBe("string"); + }); + + test("includes all transaction names", () => { + const items = [ + makeTransaction({ transaction: "GET /api/users" }), + makeTransaction({ transaction: "POST /api/data" }), + ]; + const result = stripAnsi(formatTraceTable(items)); + expect(result).toContain("GET /api/users"); + expect(result).toContain("POST /api/data"); + }); + + test("includes trace IDs", () => { + const traceId = "a".repeat(32); + const result = stripAnsi( + formatTraceTable([makeTransaction({ trace: traceId })]) + ); + expect(result).toContain(traceId); + }); + + test("includes formatted durations", () => { + const result = stripAnsi( + formatTraceTable([makeTransaction({ "transaction.duration": 1500 })]) + ); + expect(result).toContain("1.50s"); + }); + + test("shows 'unknown' for empty transaction", () => { + const result = stripAnsi( + formatTraceTable([makeTransaction({ transaction: "" })]) + ); + expect(result).toContain("unknown"); + }); +}); From e56b843bcf27249ee53f02fcc2307728079ecdbc Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 15:11:30 +0000 Subject: [PATCH 10/52] fix(formatters): address Seer code review feedback - NO_COLOR: follow no-color.org spec (any non-empty value disables color, including '0' and 'false'; only empty string leaves color on). Previously isTruthyEnv() was used which treated '0'/'false' as falsy, breaking interoperability with standard CI tooling. - buildMarkdownTable: escape cell values via escapeMarkdownCell() so pipe and backslash characters in API-supplied names/URLs don't corrupt table structure (consistent with formatLogTable, formatTraceTable, buildBreadcrumbsMarkdown). - formatIssueDetails: escape newlines in metadata.value blockquote with .replace(/\n/g, '\n> ') so multi-line error messages render correctly (same pattern already used in formatLogDetails). --- src/lib/formatters/human.ts | 2 +- src/lib/formatters/markdown.ts | 10 +++++++--- src/lib/formatters/table.ts | 13 +++++++++---- test/lib/formatters/markdown.test.ts | 14 ++++++++------ 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 00ed0c36..e22e17f7 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -678,7 +678,7 @@ export function formatIssueDetails(issue: SentryIssue): string { lines.push(""); lines.push("**Message:**"); lines.push(""); - lines.push(`> ${issue.metadata.value}`); + lines.push(`> ${issue.metadata.value.replace(/\n/g, "\n> ")}`); } if (issue.metadata?.filename) { diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 2b8e33f2..694769b6 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -97,8 +97,10 @@ function isTruthyEnv(val: string): boolean { * and changes to `process.stdout.isTTY` are picked up immediately. * * Priority (highest first): - * 1. `SENTRY_PLAIN_OUTPUT` — explicit project-specific override - * 2. `NO_COLOR` — widely-supported standard for disabling styled output + * 1. `SENTRY_PLAIN_OUTPUT` — explicit project-specific override (custom + * semantics: `"0"` / `"false"` / `""` force color on) + * 2. `NO_COLOR` — follows the no-color.org spec: any **non-empty** value + * disables color, regardless of its content (including `"0"` / `"false"`) * 3. `process.stdout.isTTY` — auto-detect interactive terminal */ export function isPlainOutput(): boolean { @@ -107,9 +109,11 @@ export function isPlainOutput(): boolean { return isTruthyEnv(plain); } + // no-color.org spec: presence of a non-empty value disables color. + // Unlike SENTRY_PLAIN_OUTPUT, "0" and "false" still mean "disable color". const noColor = process.env.NO_COLOR; if (noColor !== undefined) { - return isTruthyEnv(noColor); + return noColor !== ""; } return !process.stdout.isTTY; diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index 6dca7482..53a6c94a 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -8,7 +8,7 @@ */ import type { Writer } from "../../types/index.js"; -import { renderMarkdown } from "./markdown.js"; +import { escapeMarkdownCell, renderMarkdown } from "./markdown.js"; /** * Describes a single column in a table. @@ -29,8 +29,10 @@ export type Column = { /** * Build a markdown table string from items and column definitions. * - * ANSI escape codes in cell values survive the markdown pipeline — - * cli-table3 uses `string-width` for column width calculation. + * Cell values are escaped via {@link escapeMarkdownCell} so pipe and + * backslash characters in API-supplied strings don't break the table. + * Pre-rendered ANSI codes survive the pipeline — cli-table3 uses + * `string-width` for column width calculation. * * @param items - Row data * @param columns - Column definitions @@ -43,7 +45,10 @@ export function buildMarkdownTable( const header = `| ${columns.map((c) => c.header).join(" | ")} |`; const separator = `| ${columns.map((c) => (c.align === "right" ? "---:" : "---")).join(" | ")} |`; const rows = items - .map((item) => `| ${columns.map((c) => c.value(item)).join(" | ")} |`) + .map( + (item) => + `| ${columns.map((c) => escapeMarkdownCell(c.value(item))).join(" | ")} |` + ) .join("\n"); return `${header}\n${separator}\n${rows}`; } diff --git a/test/lib/formatters/markdown.test.ts b/test/lib/formatters/markdown.test.ts index 7158ff80..38c189d0 100644 --- a/test/lib/formatters/markdown.test.ts +++ b/test/lib/formatters/markdown.test.ts @@ -144,7 +144,7 @@ describe("isPlainOutput", () => { }); }); - test("=true → plain (case-insensitive)", () => { + test("=True → plain (any non-empty value per spec)", () => { withEnv( { SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "True" }, true, @@ -154,23 +154,25 @@ describe("isPlainOutput", () => { ); }); - test("=0 → rendered", () => { + // Per no-color.org spec (updated 2026-02-09): any non-empty value disables + // color, including "0" and "false". Only empty string leaves color enabled. + test("=0 → plain (non-empty value disables color per spec)", () => { withEnv({ SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "0" }, false, () => { - expect(isPlainOutput()).toBe(false); + expect(isPlainOutput()).toBe(true); }); }); - test("=false → rendered (case-insensitive)", () => { + test("=false → plain (non-empty value disables color per spec)", () => { withEnv( { SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "false" }, false, () => { - expect(isPlainOutput()).toBe(false); + expect(isPlainOutput()).toBe(true); } ); }); - test("='' → rendered (empty string is falsy)", () => { + test("='' → rendered (empty string leaves color enabled)", () => { withEnv({ SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "" }, false, () => { expect(isPlainOutput()).toBe(false); }); From f21ad65acb1bcaad0687a91f9f44663e620790e1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 16:12:49 +0000 Subject: [PATCH 11/52] fix(formatters): address second round of Seer/BugBot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - escapeMarkdownCell: replace newlines with space to prevent multi-line values from splitting a markdown table row across multiple lines (affects log messages, breadcrumb messages, tag values, any user content) - formatLogTable / formatTraceTable: wire into the non-follow batch list paths (log/list.ts executeSingleFetch, trace/list.ts) — replaces the formatLogsHeader + formatLogRow loop so batch mode gets proper Unicode-bordered table rendering, streaming/follow mode still uses rows - project/view.ts: restore muted() styling on the multi-project divider by importing and calling divider(60) instead of bare "─".repeat(60) --- src/commands/log/list.ts | 6 ++---- src/commands/project/view.ts | 3 ++- src/commands/trace/list.ts | 8 ++------ src/lib/formatters/markdown.ts | 9 ++++++--- test/lib/formatters/markdown.test.ts | 5 +++++ 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 2e203b66..85fece58 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -14,6 +14,7 @@ import { AuthError, stringifyUnknown } from "../../lib/errors.js"; import { formatLogRow, formatLogsHeader, + formatLogTable, writeFooter, writeJson, } from "../../lib/formatters/index.js"; @@ -115,10 +116,7 @@ async function executeSingleFetch( // Reverse for chronological order (API returns newest first, tail shows oldest first) const chronological = [...logs].reverse(); - stdout.write(formatLogsHeader()); - for (const log of chronological) { - stdout.write(formatLogRow(log)); - } + stdout.write(`${formatLogTable(chronological)}\n`); // Show footer with tip if we hit the limit const hasMore = logs.length >= flags.limit; diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index f245f519..4ad06221 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -15,6 +15,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { AuthError, ContextError } from "../../lib/errors.js"; import { + divider, formatProjectDetails, writeJson, writeOutput, @@ -182,7 +183,7 @@ function writeMultipleProjects( const target = targets[i]; if (i > 0) { - stdout.write(`\n${"─".repeat(60)}\n\n`); + stdout.write(`\n${divider(60)}\n\n`); } if (project) { diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 2d4b778f..842de79b 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -8,8 +8,7 @@ import type { SentryContext } from "../../context.js"; import { listTransactions } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { - formatTraceRow, - formatTracesHeader, + formatTraceTable, writeFooter, writeJson, } from "../../lib/formatters/index.js"; @@ -152,10 +151,7 @@ export const listCommand = buildListCommand("trace", { } stdout.write(`Recent traces in ${org}/${project}:\n\n`); - stdout.write(formatTracesHeader()); - for (const trace of traces) { - stdout.write(formatTraceRow(trace)); - } + stdout.write(`${formatTraceTable(traces)}\n`); // Show footer with tip const hasMore = traces.length >= flags.limit; diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 694769b6..1a0427e2 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -122,14 +122,17 @@ export function isPlainOutput(): boolean { /** * Escape a string for safe use inside a markdown table cell. * - * Escapes backslashes first (so the escape character itself is not - * double-interpreted), then pipe characters (the table cell delimiter). + * - Escapes backslashes first (so the escape character itself is not + * double-interpreted) + * - Escapes pipe characters (the table cell delimiter) + * - Replaces newlines with a space so multi-line values don't break the + * single-row structure of a markdown table * * @param value - Raw cell content * @returns Markdown-safe string suitable for embedding in `| cell |` syntax */ export function escapeMarkdownCell(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); + return value.replace(/\n/g, " ").replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); } /** diff --git a/test/lib/formatters/markdown.test.ts b/test/lib/formatters/markdown.test.ts index 38c189d0..72d36cb6 100644 --- a/test/lib/formatters/markdown.test.ts +++ b/test/lib/formatters/markdown.test.ts @@ -300,6 +300,11 @@ describe("escapeMarkdownCell", () => { expect(escapeMarkdownCell("")).toBe(""); }); + test("replaces newlines with a space to preserve row structure", () => { + expect(escapeMarkdownCell("line1\nline2")).toBe("line1 line2"); + expect(escapeMarkdownCell("a\nb\nc")).toBe("a b c"); + }); + test("handles multiple pipes", () => { const result = escapeMarkdownCell("a|b|c"); expect(result).toBe("a\\|b\\|c"); From cda4525d3f84abf2b53ca0e840ff9aefd1ebdd2f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 17:36:53 +0000 Subject: [PATCH 12/52] fix(formatters): address third round of bot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json: restore version to 0.14.0-dev.0, accidentally clobbered to 0.13.0-dev.0 during rebase conflict resolution - human.ts: restore green()/yellow()/muted() colors to STATUS_ICONS and STATUS_LABELS, lost during the markdown migration; unknown statuses already used statusColor() so known statuses must also be colored to avoid the inconsistency - markdown.ts mdRow: after renderInlineMarkdown(), replace residual | with U+2502 (│) so CommonMark backslash-escapes like \| that marked unescapes back to | don't visually break the pipe-delimited row layout in streaming follow mode - trace.ts: extract buildTraceRowCells() private helper shared by both formatTraceRow (streaming) and formatTraceTable (batch) so cell formatting stays consistent; re-export formatTraceRow and formatTracesHeader to make them available for future streaming use cases and keep the existing tests valid --- package.json | 2 +- src/lib/formatters/human.ts | 14 +++++---- src/lib/formatters/trace.ts | 57 ++++++++++++++++++++++--------------- 3 files changed, 43 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index bd02c641..f07fcff4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sentry", - "version": "0.13.0-dev.0", + "version": "0.14.0-dev.0", "repository": { "type": "git", "url": "git+https://github.com/getsentry/cli.git" diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index e22e17f7..670b87b1 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -28,24 +28,26 @@ import { boldUnderline, type FixabilityTier, fixabilityColor, + green, levelColor, muted, statusColor, + yellow, } from "./colors.js"; import { escapeMarkdownCell, renderMarkdown } from "./markdown.js"; // Status Formatting const STATUS_ICONS: Record = { - resolved: "✓", - unresolved: "●", - ignored: "−", + resolved: green("✓"), + unresolved: yellow("●"), + ignored: muted("−"), }; const STATUS_LABELS: Record = { - resolved: "✓ Resolved", - unresolved: "● Unresolved", - ignored: "− Ignored", + resolved: `${green("✓")} Resolved`, + unresolved: `${yellow("●")} Unresolved`, + ignored: `${muted("−")} Ignored`, }; /** Maximum features to display before truncating with "... and N more" */ diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 690ad03c..d5aa65f1 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -50,42 +50,57 @@ export function formatTraceDuration(ms: number): string { const TRACE_TABLE_COLS = ["Trace ID", "Transaction", "Duration:", "When"]; /** - * Format column header for traces list (used before per-row output). + * Extract the four cell values for a trace row. * - * In plain mode: emits a markdown table header + separator row. - * In rendered mode: emits an ANSI-muted text header with a rule separator. + * Shared by {@link formatTraceRow} (streaming) and {@link formatTraceTable} + * (batch) so cell formatting stays consistent between the two paths. * - * @returns Header string (includes trailing newline) + * @param item - Transaction list item from the API + * @returns `[traceId, transaction, duration, when]` markdown-safe strings */ -export function formatTracesHeader(): string { - const names = TRACE_TABLE_COLS.map((c) => - c.endsWith(":") ? c.slice(0, -1) : c - ); - return `${mdRow(names.map((n) => `**${n}**`))}${divider(96)}\n`; +function buildTraceRowCells( + item: TransactionListItem +): [string, string, string, string] { + return [ + `\`${item.trace}\``, + escapeMarkdownCell(item.transaction || "unknown"), + formatTraceDuration(item["transaction.duration"]), + formatRelativeTime(item.timestamp).trim(), + ]; } /** - * Format a single transaction row for the traces list. + * Format a single transaction row for streaming output (follow/live mode). * * In plain mode (non-TTY / `SENTRY_PLAIN_OUTPUT=1`): emits a markdown table * row so streamed output composes into a valid CommonMark document. - * In rendered mode (TTY): emits padded ANSI-colored text for live display. + * In rendered mode (TTY): emits ANSI-styled text via `mdRow`. * * @param item - Transaction list item from the API * @returns Formatted row string with newline */ export function formatTraceRow(item: TransactionListItem): string { - const traceId = `\`${item.trace}\``; - const transaction = escapeMarkdownCell(item.transaction || "unknown"); - const duration = formatTraceDuration(item["transaction.duration"]); - const when = formatRelativeTime(item.timestamp).trim(); - return mdRow([traceId, transaction, duration, when]); + return mdRow(buildTraceRowCells(item)); } /** - * Build a markdown table for a list of trace transactions. + * Format column header for traces list (streaming mode). * - * Pre-rendered ANSI codes in cell values are preserved through the pipeline. + * @returns Header string (includes trailing newline) + */ +export function formatTracesHeader(): string { + const names = TRACE_TABLE_COLS.map((c) => + c.endsWith(":") ? c.slice(0, -1) : c + ); + return `${mdRow(names.map((n) => `**${n}**`))}${divider(96)}\n`; +} + +/** + * Build a rendered markdown table for a batch list of trace transactions. + * + * Uses {@link buildTraceRowCells} to share cell formatting with + * {@link formatTraceRow}. Pre-rendered ANSI codes are preserved through the + * pipeline via cli-table3's `string-width`-aware column sizing. * * @param items - Transaction list items from the API * @returns Rendered terminal string with Unicode-bordered table @@ -93,14 +108,10 @@ export function formatTraceRow(item: TransactionListItem): string { export function formatTraceTable(items: TransactionListItem[]): string { const rows = items .map((item) => { - const traceId = `\`${item.trace}\``; - const transaction = escapeMarkdownCell(item.transaction || "unknown"); - const duration = formatTraceDuration(item["transaction.duration"]); - const when = formatRelativeTime(item.timestamp).trim(); + const [traceId, transaction, duration, when] = buildTraceRowCells(item); return `| ${traceId} | ${transaction} | ${duration} | ${when} |`; }) .join("\n"); - return renderMarkdown(`${mdTableHeader(TRACE_TABLE_COLS)}\n${rows}`); } From 7161a3d96e161202de38b0866602c5b35971147a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 17:52:27 +0000 Subject: [PATCH 13/52] fix(formatters): escape SDK name in event details and rootTransaction in trace summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatEventDetails (human.ts): wrap SDK name in backtick code span so names like sentry.python.aws_lambda don't render with markdown emphasis (same fix already applied to log.ts SDK field in a previous commit) - formatTraceSummary (trace.ts): wrap rootTransaction through escapeMarkdownCell() — rootOp was already in backticks but rootTransaction was embedded raw, allowing pipe/underscore characters to break the table or produce unintended emphasis --- src/lib/formatters/human.ts | 7 ++++--- src/lib/formatters/trace.ts | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 670b87b1..3200b333 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1152,11 +1152,12 @@ export function formatEventDetails( } if (event.sdk?.name || event.sdk?.version) { + // Wrap in backtick code span — SDK names like sentry.python.aws_lambda + // contain underscores that markdown would otherwise render as emphasis. const sdkName = event.sdk.name ?? "unknown"; const sdkVersion = event.sdk.version ?? ""; - infoRows.push( - `| **SDK** | ${sdkName}${sdkVersion ? ` ${sdkVersion}` : ""} |` - ); + const sdkInfo = `${sdkName}${sdkVersion ? ` ${sdkVersion}` : ""}`; + infoRows.push(`| **SDK** | \`${sdkInfo}\` |`); } if (event.release?.shortVersion) { diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index d5aa65f1..aa5ef61d 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -247,7 +247,9 @@ export function formatTraceSummary(summary: TraceSummary): string { if (summary.rootTransaction) { const opPrefix = summary.rootOp ? `[\`${summary.rootOp}\`] ` : ""; - rows.push(`| **Root** | ${opPrefix}${summary.rootTransaction} |`); + rows.push( + `| **Root** | ${opPrefix}${escapeMarkdownCell(summary.rootTransaction)} |` + ); } rows.push(`| **Duration** | ${formatTraceDuration(summary.duration)} |`); rows.push(`| **Spans** | ${summary.spanCount} |`); From c78b43319ec5bf4274a2a154910f2e2a926c4b7e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 18:11:06 +0000 Subject: [PATCH 14/52] fix(formatters): escape all user-supplied values in markdown table cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - markdown.ts: add safeCodeSpan() helper for backtick-wrapped values inside table cells — replaces | with U+2502 and newlines with space since CommonMark code spans treat backslash as literal (not escape), making \| ineffective at preventing table splitting - markdown.ts mdKvTable: apply escapeMarkdownCell() to every value so all key-value detail sections (org, project, log context, etc.) are safe against user-supplied | and newline characters - human.ts: apply escapeMarkdownCell() to all bare user-supplied values in formatIssueDetails (platform, type, assignee, project name, release versions, permalink) and buildUserMarkdown (name, email, username, id, ip, geo location) and buildEnvironmentMarkdown (browser, OS, device) - human.ts: use safeCodeSpan() for backtick-wrapped values (culprit, project slug, event location, trace ID) instead of raw template literal backtick wrapping --- src/lib/formatters/human.ts | 64 +++++++++++++++++++++------------- src/lib/formatters/markdown.ts | 21 ++++++++++- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 3200b333..045d603b 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -34,7 +34,11 @@ import { statusColor, yellow, } from "./colors.js"; -import { escapeMarkdownCell, renderMarkdown } from "./markdown.js"; +import { + escapeMarkdownCell, + renderMarkdown, + safeCodeSpan, +} from "./markdown.js"; // Status Formatting @@ -620,31 +624,35 @@ export function formatIssueDetails(issue: SentryIssue): string { levelLine += " (unhandled)"; } rows.push(`| **Level** | ${levelLine} |`); - rows.push(`| **Platform** | ${issue.platform ?? "unknown"} |`); - rows.push(`| **Type** | ${issue.type ?? "unknown"} |`); - rows.push(`| **Assignee** | ${issue.assignedTo?.name ?? "Unassigned"} |`); + rows.push( + `| **Platform** | ${escapeMarkdownCell(issue.platform ?? "unknown")} |` + ); + rows.push(`| **Type** | ${escapeMarkdownCell(issue.type ?? "unknown")} |`); + rows.push( + `| **Assignee** | ${escapeMarkdownCell(String(issue.assignedTo?.name ?? "Unassigned"))} |` + ); if (issue.project) { rows.push( - `| **Project** | ${issue.project.name} (\`${issue.project.slug}\`) |` + `| **Project** | ${escapeMarkdownCell(issue.project.name ?? "(unknown)")} (${safeCodeSpan(issue.project.slug ?? "")}) |` ); } const firstReleaseVersion = issue.firstRelease?.shortVersion; const lastReleaseVersion = issue.lastRelease?.shortVersion; if (firstReleaseVersion || lastReleaseVersion) { + const first = escapeMarkdownCell(String(firstReleaseVersion ?? "")); + const last = escapeMarkdownCell(String(lastReleaseVersion ?? "")); if (firstReleaseVersion && lastReleaseVersion) { if (firstReleaseVersion === lastReleaseVersion) { - rows.push(`| **Release** | ${firstReleaseVersion} |`); + rows.push(`| **Release** | ${first} |`); } else { - rows.push( - `| **Releases** | ${firstReleaseVersion} → ${lastReleaseVersion} |` - ); + rows.push(`| **Releases** | ${first} → ${last} |`); } } else if (lastReleaseVersion) { - rows.push(`| **Release** | ${lastReleaseVersion} |`); + rows.push(`| **Release** | ${last} |`); } else if (firstReleaseVersion) { - rows.push(`| **Release** | ${firstReleaseVersion} |`); + rows.push(`| **Release** | ${first} |`); } } @@ -667,10 +675,10 @@ export function formatIssueDetails(issue: SentryIssue): string { } if (issue.culprit) { - rows.push(`| **Culprit** | \`${issue.culprit}\` |`); + rows.push(`| **Culprit** | ${safeCodeSpan(issue.culprit)} |`); } - rows.push(`| **Link** | ${issue.permalink} |`); + rows.push(`| **Link** | ${escapeMarkdownCell(issue.permalink ?? "")} |`); lines.push("| | |"); lines.push("|---|---|"); @@ -1000,13 +1008,17 @@ function buildEnvironmentMarkdown(event: SentryEvent): string { if (contexts.browser) { const name = contexts.browser.name || "Unknown Browser"; const version = contexts.browser.version || ""; - rows.push(`| **Browser** | ${name}${version ? ` ${version}` : ""} |`); + rows.push( + `| **Browser** | ${escapeMarkdownCell(`${name}${version ? ` ${version}` : ""}`)} |` + ); } if (contexts.os) { const name = contexts.os.name || "Unknown OS"; const version = contexts.os.version || ""; - rows.push(`| **OS** | ${name}${version ? ` ${version}` : ""} |`); + rows.push( + `| **OS** | ${escapeMarkdownCell(`${name}${version ? ` ${version}` : ""}`)} |` + ); } if (contexts.device) { @@ -1014,7 +1026,7 @@ function buildEnvironmentMarkdown(event: SentryEvent): string { const brand = contexts.device.brand || ""; if (family || brand) { const device = brand ? `${family} (${brand})` : family; - rows.push(`| **Device** | ${device} |`); + rows.push(`| **Device** | ${escapeMarkdownCell(device)} |`); } } @@ -1050,19 +1062,19 @@ function buildUserMarkdown(event: SentryEvent): string { const rows: string[] = []; if (user.name) { - rows.push(`| **Name** | ${user.name} |`); + rows.push(`| **Name** | ${escapeMarkdownCell(user.name)} |`); } if (user.email) { - rows.push(`| **Email** | ${user.email} |`); + rows.push(`| **Email** | ${escapeMarkdownCell(user.email)} |`); } if (user.username) { - rows.push(`| **Username** | ${user.username} |`); + rows.push(`| **Username** | ${escapeMarkdownCell(user.username)} |`); } if (user.id) { - rows.push(`| **ID** | ${user.id} |`); + rows.push(`| **ID** | ${escapeMarkdownCell(user.id)} |`); } if (user.ip_address) { - rows.push(`| **IP** | ${user.ip_address} |`); + rows.push(`| **IP** | ${escapeMarkdownCell(user.ip_address)} |`); } if (user.geo) { @@ -1078,7 +1090,7 @@ function buildUserMarkdown(event: SentryEvent): string { parts.push(`(${geo.country_code})`); } if (parts.length > 0) { - rows.push(`| **Location** | ${parts.join(", ")} |`); + rows.push(`| **Location** | ${escapeMarkdownCell(parts.join(", "))} |`); } } @@ -1143,12 +1155,12 @@ export function formatEventDetails( ); } if (event.location) { - infoRows.push(`| **Location** | \`${event.location}\` |`); + infoRows.push(`| **Location** | ${safeCodeSpan(event.location)} |`); } const traceCtx = event.contexts?.trace; if (traceCtx?.trace_id) { - infoRows.push(`| **Trace** | \`${traceCtx.trace_id}\` |`); + infoRows.push(`| **Trace** | ${safeCodeSpan(traceCtx.trace_id)} |`); } if (event.sdk?.name || event.sdk?.version) { @@ -1161,7 +1173,9 @@ export function formatEventDetails( } if (event.release?.shortVersion) { - infoRows.push(`| **Release** | ${event.release.shortVersion} |`); + infoRows.push( + `| **Release** | ${escapeMarkdownCell(event.release.shortVersion)} |` + ); } if (infoRows.length > 0) { diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 1a0427e2..aa95808e 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -170,6 +170,25 @@ export function mdRow(cells: readonly string[]): string { return `| ${out.join(" | ")} |\n`; } +/** + * Wrap a user-supplied string in a backtick code span for use inside a + * markdown table cell. + * + * Inside a CommonMark code span, backslashes are **literal** (not escape + * characters), so `\|` would render as `\|` rather than protecting the + * table delimiter. Instead this helper: + * + * - Replaces `|` with `│` (U+2502, BOX DRAWINGS LIGHT VERTICAL) — visually + * identical but not a markdown table delimiter. + * - Replaces newlines with a space — code spans cannot span lines. + * + * @param value - Raw string to format as a code span + * @returns `` `value` `` with pipe and newline characters sanitised + */ +export function safeCodeSpan(value: string): string { + return `\`${value.replace(/\|/g, "│").replace(/\n/g, " ")}\``; +} + /** * Build a key-value markdown table section with an optional heading. * @@ -192,7 +211,7 @@ export function mdKvTable( lines.push("| | |"); lines.push("|---|---|"); for (const [label, value] of rows) { - lines.push(`| **${label}** | ${value} |`); + lines.push(`| **${label}** | ${escapeMarkdownCell(value)} |`); } return lines.join("\n"); } From 1dc5d4900ba38f29c4a7dcfeb1cfd17141111e0c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 18:31:09 +0000 Subject: [PATCH 15/52] fix(formatters): mdKvTable: use U+2502 instead of backslash-pipe for cell sanitisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mdKvTable unconditionally applied escapeMarkdownCell() to all values, including those pre-formatted as code spans (e.g. `${logId}`, `${codeFunction}`). escapeMarkdownCell() escapes backslashes first (\→\\), which inside a CommonMark code span is literal, producing double-backslashes in the rendered output (e.g. src\\lib\\parser.ts). Replace escapeMarkdownCell with a new sanitizeKvCell() private helper that only substitutes | with │ (U+2502 BOX DRAWINGS LIGHT VERTICAL, visually identical, never a table delimiter) and newlines with a space. This avoids: - Double-escaping backslashes inside code spans - Reintroducing CodeQL 'incomplete escaping' concern (no backslash escape chain is used at all — character substitution only) - Table structure corruption from user-supplied pipe characters --- src/lib/formatters/markdown.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index aa95808e..2d4bf773 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -189,12 +189,36 @@ export function safeCodeSpan(value: string): string { return `\`${value.replace(/\|/g, "│").replace(/\n/g, " ")}\``; } +/** + * Sanitise a pre-formatted table cell value for safe embedding in a raw + * markdown `| cell |` row. + * + * Unlike {@link escapeMarkdownCell}, this helper does **not** backslash-escape + * the backslash character. It is designed for values that may already contain + * inline markdown formatting (e.g. code spans) where `\\` would corrupt the + * rendered output. Instead of `\|`, pipe characters are replaced with + * `│` (U+2502, BOX DRAWINGS LIGHT VERTICAL) — visually identical but not a + * CommonMark table delimiter. + * + * @param value - Cell value that may include inline markdown (backtick spans, + * bold, etc.) + * @returns Value with `|` replaced by `│` and newlines collapsed to a space + */ +function sanitizeKvCell(value: string): string { + return value.replace(/\n/g, " ").replace(/\|/g, "│"); +} + /** * Build a key-value markdown table section with an optional heading. * * Each entry is rendered as `| **Label** | value |`. * Uses the blank-header-row pattern required by marked-terminal. * + * Values are sanitised via {@link sanitizeKvCell} — callers may pass raw text + * or pre-formatted inline markdown (e.g. `` `code` ``, `**bold**`). Raw text + * values with user-supplied content should be wrapped in {@link safeCodeSpan} + * or {@link escapeMarkdownCell} before passing if precise escaping is needed. + * * @param rows - `[label, value]` tuples * @param heading - Optional `### Heading` text (omit the `###` prefix) * @returns Raw markdown string (not rendered) @@ -211,7 +235,7 @@ export function mdKvTable( lines.push("| | |"); lines.push("|---|---|"); for (const [label, value] of rows) { - lines.push(`| **${label}** | ${escapeMarkdownCell(value)} |`); + lines.push(`| **${label}** | ${sanitizeKvCell(value)} |`); } return lines.join("\n"); } From 7234d747a30dd0e465c9255ca6b8a92bdb250489 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 18:52:15 +0000 Subject: [PATCH 16/52] fix(formatters): apply missing mdRow pipe-bleed-through guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commit message for 2b7b534 described replacing residual | with U+2502 in mdRow's rendered path, but the actual code change to markdown.ts was never committed — the mdRow implementation still called renderInlineMarkdown without the post-render sanitisation. Apply the fix: after renderInlineMarkdown(), replace all | (including those that marked.parseInline unescapes from CommonMark \| backslash-escapes) with │ (U+2502, BOX DRAWINGS LIGHT VERTICAL) so pipe characters in log messages, transaction names, and other user content cannot visually corrupt the pipe-delimited column structure in streaming TTY output. --- src/lib/formatters/markdown.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 2d4bf773..372dfcce 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -156,17 +156,24 @@ export function mdTableHeader(cols: readonly string[]): string { /** * Build a markdown table row from cell values. * - * In plain mode the cells are emitted as-is (raw CommonMark). - * In rendered mode each cell is passed through `renderInlineMarkdown()` - * so inline constructs like `**bold**` and `` `code` `` become ANSI-styled. + * In plain mode the cells are emitted as-is (raw CommonMark), so callers + * should pre-escape pipe characters via {@link escapeMarkdownCell}. * - * @param cells - Cell values (may contain inline markdown) + * In rendered mode each cell is passed through `renderInlineMarkdown()` so + * inline constructs like `**bold**` and `` `code` `` become ANSI-styled. + * After rendering, any remaining `|` characters (including those that + * `marked.parseInline` unescapes from CommonMark `\|` escapes) are replaced + * with `│` (U+2502, BOX DRAWINGS LIGHT VERTICAL) so they do not visually + * corrupt the pipe-delimited column structure in the terminal output. + * + * @param cells - Cell values (may contain inline markdown or escaped pipes) * @returns `| a | b |\n` */ export function mdRow(cells: readonly string[]): string { - const out = isPlainOutput() - ? cells - : cells.map((c) => renderInlineMarkdown(c)); + if (isPlainOutput()) { + return `| ${cells.join(" | ")} |\n`; + } + const out = cells.map((c) => renderInlineMarkdown(c).replace(/\|/g, "│")); return `| ${out.join(" | ")} |\n`; } From faa9086868cebb2621d62bb140f6ae99b7be2203 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 19:09:59 +0000 Subject: [PATCH 17/52] fix(formatters): escape markdown inline chars in issue titles and remove unused minWidth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - markdown.ts: add escapeMarkdownInline() which escapes \, *, _, `, [, ] — the characters that CommonMark processes as inline emphasis, strong, code spans, and links. Prevents Python/JS exception titles like 'TypeError in __init__' from losing underscores (rendered as empty emphasis) when passed through renderMarkdown() in headings/blockquotes. - human.ts formatIssueDetails: apply escapeMarkdownInline() to issue.title in the '## shortId: title' heading and to metadata.value in blockquotes - human.ts formatEventDetails: apply escapeMarkdownInline() to the event header string used in '## header (`eventId`)' headings - table.ts: remove minWidth from Column interface — buildMarkdownTable never used it (auto-sized by cli-table3), so the property was dead configuration; remove corresponding dead minWidth values from repo/list.ts and team/list.ts callers --- src/commands/repo/list.ts | 8 ++++---- src/commands/team/list.ts | 7 +++---- src/lib/formatters/human.ts | 11 ++++++++--- src/lib/formatters/markdown.ts | 25 +++++++++++++++++++++++++ src/lib/formatters/table.ts | 1 - 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/commands/repo/list.ts b/src/commands/repo/list.ts index a667b037..374ae6e0 100644 --- a/src/commands/repo/list.ts +++ b/src/commands/repo/list.ts @@ -30,10 +30,10 @@ type RepositoryWithOrg = SentryRepository & { orgSlug?: string }; /** Column definitions for the repository table. */ const REPO_COLUMNS: Column[] = [ - { header: "ORG", value: (r) => r.orgSlug || "", minWidth: 3 }, - { header: "NAME", value: (r) => r.name, minWidth: 4 }, - { header: "PROVIDER", value: (r) => r.provider.name, minWidth: 8 }, - { header: "STATUS", value: (r) => r.status, minWidth: 6 }, + { header: "ORG", value: (r) => r.orgSlug || "" }, + { header: "NAME", value: (r) => r.name }, + { header: "PROVIDER", value: (r) => r.provider.name }, + { header: "STATUS", value: (r) => r.status }, { header: "URL", value: (r) => r.url || "" }, ]; diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts index a820274e..b9abd3ab 100644 --- a/src/commands/team/list.ts +++ b/src/commands/team/list.ts @@ -31,14 +31,13 @@ type TeamWithOrg = SentryTeam & { orgSlug?: string }; /** Column definitions for the team table. */ const TEAM_COLUMNS: Column[] = [ - { header: "ORG", value: (t) => t.orgSlug || "", minWidth: 3 }, - { header: "SLUG", value: (t) => t.slug, minWidth: 4 }, - { header: "NAME", value: (t) => t.name, minWidth: 4 }, + { header: "ORG", value: (t) => t.orgSlug || "" }, + { header: "SLUG", value: (t) => t.slug }, + { header: "NAME", value: (t) => t.name }, { header: "MEMBERS", value: (t) => String(t.memberCount ?? ""), align: "right", - minWidth: 7, }, ]; diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 045d603b..4a3f132e 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -36,6 +36,7 @@ import { } from "./colors.js"; import { escapeMarkdownCell, + escapeMarkdownInline, renderMarkdown, safeCodeSpan, } from "./markdown.js"; @@ -596,7 +597,7 @@ export function formatIssueRow( export function formatIssueDetails(issue: SentryIssue): string { const lines: string[] = []; - lines.push(`## ${issue.shortId}: ${issue.title}`); + lines.push(`## ${issue.shortId}: ${escapeMarkdownInline(issue.title ?? "")}`); lines.push(""); // Key-value details as a table @@ -688,7 +689,9 @@ export function formatIssueDetails(issue: SentryIssue): string { lines.push(""); lines.push("**Message:**"); lines.push(""); - lines.push(`> ${issue.metadata.value.replace(/\n/g, "\n> ")}`); + lines.push( + `> ${escapeMarkdownInline(issue.metadata.value).replace(/\n/g, "\n> ")}` + ); } if (issue.metadata?.filename) { @@ -1143,7 +1146,9 @@ export function formatEventDetails( return withSerializeSpan("formatEventDetails", () => { const sections: string[] = []; - sections.push(`## ${header} (\`${event.eventID.slice(0, 8)}\`)`); + sections.push( + `## ${escapeMarkdownInline(header)} (\`${event.eventID.slice(0, 8)}\`)` + ); sections.push(""); // Basic info table diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 372dfcce..6d108cc3 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -135,6 +135,31 @@ export function escapeMarkdownCell(value: string): string { return value.replace(/\n/g, " ").replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); } +/** + * Escape CommonMark inline emphasis characters in a string for safe use inside + * markdown headings, blockquotes, and other inline contexts. + * + * This prevents characters like `_` (emphasis trigger) and `*` (strong trigger) + * in user-supplied content from being consumed by the markdown parser. + * For example, a Python exception title `TypeError in __init__` would render as + * `TypeError in init` (underscores consumed as empty emphasis) without escaping. + * + * Escaped characters: `\`, `*`, `_`, `` ` ``, `[`, `]`. + * + * @param value - Raw text (e.g. issue title, exception message) + * @returns String safe for inline use inside a marked rendering context + */ +export function escapeMarkdownInline(value: string): string { + // Escape backslash first so we don't double-escape subsequent substitutions + return value + .replace(/\\/g, "\\\\") + .replace(/\*/g, "\\*") + .replace(/_/g, "\\_") + .replace(/`/g, "\\`") + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]"); +} + /** * Build a raw markdown table header row + separator from column names. * diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index 53a6c94a..8d54ab89 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -23,7 +23,6 @@ export type Column = { /** Column alignment. Defaults to "left". */ align?: "left" | "right"; /** Minimum column width (header width is always respected) */ - minWidth?: number; }; /** From b9df73de90a69fd427d87145358f650f81fe609c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 19:25:24 +0000 Subject: [PATCH 18/52] fix(formatters): restore severity color in formatLogRow and remove dangling JSDoc - log.ts: formatLogRow was using '**LEVEL**' (uniform bold, no color) for the severity cell, while formatLogTable correctly used formatSeverity() for per-level ANSI color (red/error, yellow/warning, cyan/info, muted/debug). Restore formatSeverity() in formatLogRow to keep streaming and batch modes consistent. - table.ts: remove the dangling JSDoc comment '/** Minimum column width... */' left behind when the minWidth property was removed from Column in the previous commit. --- src/lib/formatters/log.ts | 4 +++- src/lib/formatters/table.ts | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index c495fafd..87b5d251 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -74,7 +74,9 @@ function formatTimestamp(timestamp: string): string { */ export function formatLogRow(log: SentryLog): string { const timestamp = formatTimestamp(log.timestamp); - const level = `**${(log.severity ?? "info").toUpperCase()}**`; + // Use formatSeverity() for per-level ANSI color (red/yellow/cyan/muted), + // matching the batch-mode formatLogTable path. + const level = formatSeverity(log.severity); const message = escapeMarkdownCell(log.message ?? ""); const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; return mdRow([timestamp, level, `${message}${trace}`]); diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index 8d54ab89..8083a8f1 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -22,7 +22,6 @@ export type Column = { value: (item: T) => string; /** Column alignment. Defaults to "left". */ align?: "left" | "right"; - /** Minimum column width (header width is always respected) */ }; /** From 6d51cf28a2aabce649d69b73dbea57fd3e5bb152 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 19:43:01 +0000 Subject: [PATCH 19/52] fix(formatters): escape org/project names in detail views and log message blockquotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - human.ts formatOrgDetails: escape org.name with escapeMarkdownCell() in the kv table row and escapeMarkdownInline() in the '## slug: name' heading - human.ts formatProjectDetails: escape project.name, project.platform, and project.organization.name with escapeMarkdownCell(); use safeCodeSpan() for project.organization.slug; apply escapeMarkdownInline() to heading - log.ts formatLogDetails: escape log.message with escapeMarkdownInline() before embedding in the blockquote — same pattern already applied to issue.metadata.value in human.ts; prevents __, *, and backtick chars in Python tracebacks or format strings from corrupting the rendered output --- src/lib/formatters/human.ts | 20 ++++++++++++++------ src/lib/formatters/log.ts | 3 ++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 4a3f132e..d7906992 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1282,12 +1282,14 @@ export function calculateOrgSlugWidth(orgs: SentryOrganization[]): number { export function formatOrgDetails(org: SentryOrganization): string { const lines: string[] = []; - lines.push(`## ${org.slug}: ${org.name || "(unnamed)"}`); + lines.push( + `## ${escapeMarkdownInline(org.slug)}: ${escapeMarkdownInline(org.name || "(unnamed)")}` + ); lines.push(""); const rows: string[] = []; rows.push(`| **Slug** | \`${org.slug || "(none)"}\` |`); - rows.push(`| **Name** | ${org.name || "(unnamed)"} |`); + rows.push(`| **Name** | ${escapeMarkdownCell(org.name || "(unnamed)")} |`); rows.push(`| **ID** | ${org.id} |`); if (org.dateCreated) { rows.push( @@ -1360,14 +1362,20 @@ export function formatProjectDetails( ): string { const lines: string[] = []; - lines.push(`## ${project.slug}: ${project.name || "(unnamed)"}`); + lines.push( + `## ${escapeMarkdownInline(project.slug)}: ${escapeMarkdownInline(project.name || "(unnamed)")}` + ); lines.push(""); const rows: string[] = []; rows.push(`| **Slug** | \`${project.slug || "(none)"}\` |`); - rows.push(`| **Name** | ${project.name || "(unnamed)"} |`); + rows.push( + `| **Name** | ${escapeMarkdownCell(project.name || "(unnamed)")} |` + ); rows.push(`| **ID** | ${project.id} |`); - rows.push(`| **Platform** | ${project.platform || "Not set"} |`); + rows.push( + `| **Platform** | ${escapeMarkdownCell(project.platform || "Not set")} |` + ); rows.push(`| **DSN** | \`${dsn || "No DSN available"}\` |`); rows.push(`| **Status** | ${project.status} |`); if (project.dateCreated) { @@ -1377,7 +1385,7 @@ export function formatProjectDetails( } if (project.organization) { rows.push( - `| **Organization** | ${project.organization.name} (\`${project.organization.slug}\`) |` + `| **Organization** | ${escapeMarkdownCell(project.organization.name)} (${safeCodeSpan(project.organization.slug)}) |` ); } if (project.firstEvent) { diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 87b5d251..db122974 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -10,6 +10,7 @@ import { cyan, muted, red, yellow } from "./colors.js"; import { divider, escapeMarkdownCell, + escapeMarkdownInline, mdKvTable, mdRow, mdTableHeader, @@ -160,7 +161,7 @@ export function formatLogDetails( lines.push(""); lines.push("**Message:**"); lines.push(""); - lines.push(`> ${log.message.replace(/\n/g, "\n> ")}`); + lines.push(`> ${escapeMarkdownInline(log.message).replace(/\n/g, "\n> ")}`); } // Context section From 1d4d72c382ec690b356544d9abe07dfbc2b78ec8 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 20:02:35 +0000 Subject: [PATCH 20/52] fix(formatters): prevent backticks in exception values and stack frames from breaking code spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - markdown.ts safeCodeSpan: also replace ` with ˋ (U+02CB MODIFIER LETTER GRAVE ACCENT) — visually identical in monospace but never a code-span delimiter. Prevents JS exception messages like 'Unexpected token `' from prematurely closing the backtick-delimited code span. - human.ts formatStackFrameMarkdown: use safeCodeSpan() for the full 'at fn (file:line:col)' string instead of raw template-literal backticks - human.ts formatExceptionValueMarkdown: use safeCodeSpan() for the 'type: value' header instead of raw backticks --- src/lib/formatters/human.ts | 4 ++-- src/lib/formatters/markdown.ts | 24 ++++++++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index d7906992..5b090ee6 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -718,7 +718,7 @@ function formatStackFrameMarkdown(frame: StackFrame): string { const col = frame.colNo ?? "?"; const inAppTag = frame.inApp ? " `[in-app]`" : ""; - lines.push(`\`at ${fn} (${file}:${line}:${col})\`${inAppTag}`); + lines.push(`${safeCodeSpan(`at ${fn} (${file}:${line}:${col})`)}${inAppTag}`); if (frame.context && frame.context.length > 0) { lines.push(""); @@ -743,7 +743,7 @@ function formatExceptionValueMarkdown(exception: ExceptionValue): string { const type = exception.type || "Error"; const value = exception.value || ""; - lines.push(`**\`${type}: ${value}\`**`); + lines.push(`**${safeCodeSpan(`${type}: ${value}`)}**`); if (exception.mechanism) { const handled = exception.mechanism.handled ? "handled" : "unhandled"; diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 6d108cc3..6b118406 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -203,22 +203,26 @@ export function mdRow(cells: readonly string[]): string { } /** - * Wrap a user-supplied string in a backtick code span for use inside a - * markdown table cell. + * Wrap a user-supplied string in a backtick code span. * * Inside a CommonMark code span, backslashes are **literal** (not escape - * characters), so `\|` would render as `\|` rather than protecting the - * table delimiter. Instead this helper: - * - * - Replaces `|` with `│` (U+2502, BOX DRAWINGS LIGHT VERTICAL) — visually - * identical but not a markdown table delimiter. - * - Replaces newlines with a space — code spans cannot span lines. + * characters), so backslash-based escaping of special characters does not + * work. This helper sanitises the three characters that would otherwise break + * the span or the surrounding table structure: + * + * - Replaces `` ` `` with `ˋ` (U+02CB MODIFIER LETTER GRAVE ACCENT) — + * visually identical in monospace fonts but never treated as a code-span + * delimiter. Prevents exception messages like `Unexpected token \`` from + * prematurely closing the span. + * - Replaces `|` with `│` (U+2502 BOX DRAWINGS LIGHT VERTICAL) — prevents + * table column splitting when used inside a markdown table cell. + * - Replaces newlines with a space — code spans cannot span multiple lines. * * @param value - Raw string to format as a code span - * @returns `` `value` `` with pipe and newline characters sanitised + * @returns `` `value` `` with backtick, pipe, and newline characters sanitised */ export function safeCodeSpan(value: string): string { - return `\`${value.replace(/\|/g, "│").replace(/\n/g, " ")}\``; + return `\`${value.replace(/`/g, "ˋ").replace(/\|/g, "│").replace(/\n/g, " ")}\``; } /** From 0a6f0ba149e8ba61a5a7174c41db32929b74557c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 20:20:21 +0000 Subject: [PATCH 21/52] fix(formatters): use divider() in issue/list.ts and escape Seer text in headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - issue/list.ts: replace inline muted("─".repeat(n)) with divider(n) from markdown.ts — the helper already exists and is re-exported via the formatters index; inlining it duplicated the helper logic - seer.ts buildRootCauseMarkdown: escape cause.description with escapeMarkdownInline() before embedding in the '### Cause #N: ...' heading - seer.ts formatSolution: escape solution.data.one_line_summary with escapeMarkdownInline() before embedding in '**Summary:** ...' bold text — Seer AI-generated text can contain underscores, asterisks, and backticks which the markdown parser would interpret as emphasis or code spans --- src/commands/issue/list.ts | 3 ++- src/lib/formatters/seer.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 4e21b2f4..773890df 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -35,6 +35,7 @@ import { ValidationError, } from "../../lib/errors.js"; import { + divider, type FormatShortIdOptions, formatIssueListHeader, formatIssueRow, @@ -117,7 +118,7 @@ function writeListHeader( ): void { stdout.write(`${title}:\n\n`); stdout.write(muted(`${formatIssueListHeader(isMultiProject)}\n`)); - stdout.write(muted(`${"─".repeat(isMultiProject ? 96 : 80)}\n`)); + stdout.write(`${divider(isMultiProject ? 96 : 80)}\n`); } /** Issue with formatting options attached */ diff --git a/src/lib/formatters/seer.ts b/src/lib/formatters/seer.ts index 9762af39..1f67e4b8 100644 --- a/src/lib/formatters/seer.ts +++ b/src/lib/formatters/seer.ts @@ -12,7 +12,7 @@ import type { } from "../../types/seer.js"; import { SeerError } from "../errors.js"; import { cyan } from "./colors.js"; -import { renderMarkdown } from "./markdown.js"; +import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; // Spinner Frames @@ -107,7 +107,9 @@ export function getProgressMessage(state: AutofixState): string { function buildRootCauseMarkdown(cause: RootCause, index: number): string { const lines: string[] = []; - lines.push(`### Cause #${index}: ${cause.description}`); + lines.push( + `### Cause #${index}: ${escapeMarkdownInline(cause.description ?? "")}` + ); lines.push(""); if (cause.relevant_repos && cause.relevant_repos.length > 0) { @@ -257,7 +259,9 @@ export function formatSolution(solution: SolutionArtifact): string { lines.push("## Solution"); lines.push(""); - lines.push(`**Summary:** ${solution.data.one_line_summary}`); + lines.push( + `**Summary:** ${escapeMarkdownInline(solution.data.one_line_summary ?? "")}` + ); lines.push(""); if (solution.data.steps.length > 0) { From 42adad37d3ffbf1c9af0abc4e88527db6f731225 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 20:52:00 +0000 Subject: [PATCH 22/52] fix(e2e): increase bundle test timeout to handle cold CI runner startups --- test/e2e/bundle.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/bundle.test.ts b/test/e2e/bundle.test.ts index 182009a5..2c89dd3b 100644 --- a/test/e2e/bundle.test.ts +++ b/test/e2e/bundle.test.ts @@ -85,7 +85,7 @@ describe("npm bundle", () => { // Even if it exits non-zero due to missing config, it should run as JS not shell const output = stdout + stderr; expect(output.length).toBeGreaterThan(0); - }); + }, 15_000); // Allow up to 15s for cold Node.js JIT startup on slow CI runners test("bundle does not emit Node.js warnings", async () => { // Run the bundle and capture stderr to check for warnings @@ -103,7 +103,7 @@ describe("npm bundle", () => { expect(stderr).not.toContain("ExperimentalWarning"); expect(stderr).not.toContain("DeprecationWarning"); expect(stderr).not.toContain("Warning:"); - }); + }, 15_000); // Allow up to 15s for cold Node.js JIT startup on slow CI runners test("bundle can be executed directly on Unix", async () => { // Skip on Windows where shebang doesn't apply @@ -134,5 +134,5 @@ describe("npm bundle", () => { // The CLI should start and produce some output const output = stdout + stderr; expect(output.length).toBeGreaterThan(0); - }); + }, 15_000); // Allow up to 15s for cold Node.js JIT startup on slow CI runners }); From 34da2755b032e977c24e44c82088f6025e1657d7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Feb 2026 21:10:52 +0000 Subject: [PATCH 23/52] fix(formatters): escape step titles in Seer output and remove dead string[] shim in writeOutput --- src/lib/formatters/output.ts | 7 +++---- src/lib/formatters/seer.ts | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index 44f9052f..4cb23b84 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -12,8 +12,8 @@ import { writeJson } from "./json.js"; type WriteOutputOptions = { /** Output JSON format instead of human-readable */ json: boolean; - /** Function to format data as a rendered string or string array */ - formatHuman: (data: T) => string | string[]; + /** Function to format data as a rendered string */ + formatHuman: (data: T) => string; /** Optional source description if data was auto-detected */ detectedFrom?: string; }; @@ -36,8 +36,7 @@ export function writeOutput( return; } - const output = options.formatHuman(data); - const text = Array.isArray(output) ? output.join("\n") : output; + const text = options.formatHuman(data); stdout.write(`${text}\n`); if (options.detectedFrom) { diff --git a/src/lib/formatters/seer.ts b/src/lib/formatters/seer.ts index 1f67e4b8..b5cb8b31 100644 --- a/src/lib/formatters/seer.ts +++ b/src/lib/formatters/seer.ts @@ -124,7 +124,7 @@ function buildRootCauseMarkdown(cause: RootCause, index: number): string { lines.push("**Reproduction steps:**"); lines.push(""); for (const step of cause.root_cause_reproduction) { - lines.push(`**${step.title}**`); + lines.push(`**${escapeMarkdownInline(step.title)}**`); lines.push(""); // code_snippet_and_analysis may itself contain markdown (code fences, // inline code, etc.) — pass it through as-is so marked renders it. @@ -270,7 +270,7 @@ export function formatSolution(solution: SolutionArtifact): string { for (let i = 0; i < solution.data.steps.length; i++) { const step = solution.data.steps[i]; if (step) { - lines.push(`${i + 1}. **${step.title}**`); + lines.push(`${i + 1}. **${escapeMarkdownInline(step.title)}**`); lines.push(""); // step.description may contain markdown — pass it through as-is lines.push(` ${step.description.split("\n").join("\n ")}`); From dbd19a28316131ffd635770c6465035ffc02d0c1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 10:53:00 +0000 Subject: [PATCH 24/52] refactor(formatters): migrate org/project list to markdown tables, stub out node-emoji in npm bundle - Convert org list and project list from manual padEnd formatting to writeTable() with Column[] definitions (matching repo/team list) - Remove dead formatOrgRow, calculateOrgSlugWidth, formatProjectRow, calculateProjectColumnWidths, writeHeader, writeRows from human.ts and project/list.ts - Add esbuild plugin to stub out node-emoji (208KB) in npm bundle since we never use emoji shortcodes (emoji: false) - Disable emoji in marked-terminal config --- script/bundle.ts | 27 ++++- src/commands/org/list.ts | 46 ++++---- src/commands/project/list.ts | 58 +++------- src/lib/formatters/human.ts | 55 ---------- src/lib/formatters/markdown.ts | 6 +- test/commands/project/list.test.ts | 52 --------- test/lib/formatters/human.details.test.ts | 126 ---------------------- 7 files changed, 61 insertions(+), 309 deletions(-) diff --git a/script/bundle.ts b/script/bundle.ts index d50b8143..2b9f4eb2 100644 --- a/script/bundle.ts +++ b/script/bundle.ts @@ -31,6 +31,7 @@ if (!SENTRY_CLIENT_ID) { // Regex patterns for esbuild plugin (must be top-level for performance) const BUN_SQLITE_FILTER = /^bun:sqlite$/; +const NODE_EMOJI_FILTER = /^node-emoji$/; const ANY_FILTER = /.*/; // Plugin to replace bun:sqlite with our node:sqlite polyfill @@ -59,8 +60,32 @@ const bunSqlitePlugin: Plugin = { }, }; +/** + * Plugin to stub out `node-emoji` — a 208KB emoji library that + * `marked-terminal` statically imports but we never use (emoji: false). + * The static `import * as emoji from 'node-emoji'` prevents tree-shaking, + * so we replace it with a no-op stub at build time. + */ +const nodeEmojiStubPlugin: Plugin = { + name: "node-emoji-stub", + setup(pluginBuild) { + pluginBuild.onResolve({ filter: NODE_EMOJI_FILTER }, () => ({ + path: "node-emoji", + namespace: "node-emoji-stub", + })); + + pluginBuild.onLoad( + { filter: ANY_FILTER, namespace: "node-emoji-stub" }, + () => ({ + contents: "export function get(s) { return s; }", + loader: "js", + }) + ); + }, +}; + // Configure Sentry plugin for source map uploads (production builds only) -const plugins: Plugin[] = [bunSqlitePlugin]; +const plugins: Plugin[] = [bunSqlitePlugin, nodeEmojiStubPlugin]; if (process.env.SENTRY_AUTH_TOKEN) { console.log(" Sentry auth token found, source maps will be uploaded"); diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index a56ebe07..df0e30e3 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -9,12 +9,8 @@ import { listOrganizations } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { getAllOrgRegions } from "../../lib/db/regions.js"; -import { - calculateOrgSlugWidth, - formatOrgRow, - writeFooter, - writeJson, -} from "../../lib/formatters/index.js"; +import { writeFooter, writeJson } from "../../lib/formatters/index.js"; +import { type Column, writeTable } from "../../lib/formatters/table.js"; import { buildListLimitFlag, LIST_JSON_FLAG } from "../../lib/list-command.js"; type ListFlags = { @@ -97,29 +93,25 @@ export const listCommand = buildCommand({ const uniqueRegions = new Set(orgRegions.values()); const showRegion = uniqueRegions.size > 1; - const slugWidth = calculateOrgSlugWidth(limitedOrgs); + // Build columns — include REGION when orgs span multiple regions + type OrgRow = { slug: string; name: string; region?: string }; + const rows: OrgRow[] = limitedOrgs.map((org) => ({ + slug: org.slug, + name: org.name, + region: showRegion + ? getRegionDisplayName(orgRegions.get(org.slug) ?? "") + : undefined, + })); - // Header - if (showRegion) { - stdout.write( - `${"SLUG".padEnd(slugWidth)} ${"REGION".padEnd(6)} NAME\n` - ); - } else { - stdout.write(`${"SLUG".padEnd(slugWidth)} NAME\n`); - } + const columns: Column[] = [ + { header: "SLUG", value: (r) => r.slug }, + ...(showRegion + ? [{ header: "REGION", value: (r: OrgRow) => r.region ?? "" }] + : []), + { header: "NAME", value: (r) => r.name }, + ]; - // Rows - for (const org of limitedOrgs) { - if (showRegion) { - const regionUrl = orgRegions.get(org.slug) ?? ""; - const regionName = getRegionDisplayName(regionUrl); - stdout.write( - `${org.slug.padEnd(slugWidth)} ${regionName.padEnd(6)} ${org.name}\n` - ); - } else { - stdout.write(`${formatOrgRow(org, slugWidth)}\n`); - } - } + writeTable(stdout, rows, columns); if (orgs.length > flags.limit) { stdout.write( diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 122816b4..fbebd253 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -31,12 +31,8 @@ import { setPaginationCursor, } from "../../lib/db/pagination.js"; import { AuthError, ContextError } from "../../lib/errors.js"; -import { - calculateProjectColumnWidths, - formatProjectRow, - writeFooter, - writeJson, -} from "../../lib/formatters/index.js"; +import { writeFooter, writeJson } from "../../lib/formatters/index.js"; +import { type Column, writeTable } from "../../lib/formatters/table.js"; import { buildListCommand, buildListLimitFlag, @@ -147,41 +143,6 @@ export function filterByPlatform( ); } -/** - * Write the column header row for project list output. - */ -export function writeHeader( - stdout: Writer, - orgWidth: number, - slugWidth: number, - nameWidth: number -): void { - const org = "ORG".padEnd(orgWidth); - const project = "PROJECT".padEnd(slugWidth); - const name = "NAME".padEnd(nameWidth); - stdout.write(`${org} ${project} ${name} PLATFORM\n`); -} - -export type WriteRowsOptions = { - stdout: Writer; - projects: ProjectWithOrg[]; - orgWidth: number; - slugWidth: number; - nameWidth: number; -}; - -/** - * Write formatted project rows to stdout. - */ -export function writeRows(options: WriteRowsOptions): void { - const { stdout, projects, orgWidth, slugWidth, nameWidth } = options; - for (const project of projects) { - stdout.write( - `${formatProjectRow(project, { orgWidth, slugWidth, nameWidth })}\n` - ); - } -} - /** * Build a context key for pagination cursor validation. * Captures the query parameters that affect result ordering, @@ -264,15 +225,20 @@ async function resolveOrgsForAutoDetect(cwd: string): Promise { return { orgs: [] }; } -/** Display projects in table format with header and rows */ +/** Column definitions for the project table. */ +const PROJECT_COLUMNS: Column[] = [ + { header: "ORG", value: (p) => p.orgSlug || "" }, + { header: "PROJECT", value: (p) => p.slug }, + { header: "NAME", value: (p) => p.name }, + { header: "PLATFORM", value: (p) => p.platform || "" }, +]; + +/** Display projects in table format. */ export function displayProjectTable( stdout: Writer, projects: ProjectWithOrg[] ): void { - const { orgWidth, slugWidth, nameWidth } = - calculateProjectColumnWidths(projects); - writeHeader(stdout, orgWidth, slugWidth, nameWidth); - writeRows({ stdout, projects, orgWidth, slugWidth, nameWidth }); + writeTable(stdout, projects, PROJECT_COLUMNS); } /** diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 5b090ee6..5ba14d71 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1256,23 +1256,6 @@ export function formatEventDetails( // Organization Formatting -/** - * Format organization for list display - */ -export function formatOrgRow( - org: SentryOrganization, - slugWidth: number -): string { - return `${org.slug.padEnd(slugWidth)} ${org.name}`; -} - -/** - * Calculate max slug width from organizations - */ -export function calculateOrgSlugWidth(orgs: SentryOrganization[]): number { - return Math.max(...orgs.map((o) => o.slug.length), 4); -} - /** * Format detailed organization information as rendered markdown. * @@ -1311,44 +1294,6 @@ export function formatOrgDetails(org: SentryOrganization): string { return renderMarkdown(lines.join("\n")); } -// Project Formatting - -type ProjectRowOptions = { - orgWidth: number; - slugWidth: number; - nameWidth: number; -}; - -/** - * Format project for list display - */ -export function formatProjectRow( - project: SentryProject & { orgSlug?: string }, - options: ProjectRowOptions -): string { - const { orgWidth, slugWidth, nameWidth } = options; - const org = (project.orgSlug || "").padEnd(orgWidth); - const slug = project.slug.padEnd(slugWidth); - const name = project.name.padEnd(nameWidth); - const platform = project.platform || ""; - return `${org} ${slug} ${name} ${platform}`; -} - -/** - * Calculate column widths for project list display - */ -export function calculateProjectColumnWidths( - projects: Array -): { orgWidth: number; slugWidth: number; nameWidth: number } { - const orgWidth = Math.max( - ...projects.map((p) => (p.orgSlug || "").length), - 3 - ); - const slugWidth = Math.max(...projects.map((p) => p.slug.length), 7); - const nameWidth = Math.max(...projects.map((p) => p.name.length), 4); - return { orgWidth, slugWidth, nameWidth }; -} - /** * Format detailed project information as rendered markdown. * diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 6b118406..20fbe35e 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -70,8 +70,10 @@ marked.use( // Unescape HTML entities produced by the markdown parser unescape: true, - // Render emoji shortcodes (e.g. :tada:) - emoji: true, + // Disabled — we never use emoji shortcodes, and node-emoji pulls in + // emojilib (208KB) which marked-terminal statically imports regardless + // of this setting. The npm bundle stubs it out via esbuild plugin. + emoji: false, // Two-space tabs for code blocks tab: 2, diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index 2677565c..dc84db25 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -27,8 +27,6 @@ import { handleOrgAll, handleProjectSearch, PAGINATION_KEY, - writeHeader, - writeRows, writeSelfHostedWarning, } from "../../../src/commands/project/list.js"; import type { ParsedOrgProject } from "../../../src/lib/arg-parsing.js"; @@ -299,56 +297,6 @@ describe("resolveOrgCursor", () => { }); }); -describe("writeHeader", () => { - test("writes formatted header line", () => { - const { writer, output } = createCapture(); - writeHeader(writer, 10, 15, 20); - const line = output(); - expect(line).toContain("ORG"); - expect(line).toContain("PROJECT"); - expect(line).toContain("NAME"); - expect(line).toContain("PLATFORM"); - expect(line).toEndWith("\n"); - }); - - test("respects column widths", () => { - const { writer, output } = createCapture(); - writeHeader(writer, 5, 10, 8); - const line = output(); - // "ORG" padded to 5, "PROJECT" padded to 10, "NAME" padded to 8 - expect(line).toMatch(/^ORG\s{2}\s+PROJECT\s+NAME\s+PLATFORM\n$/); - }); -}); - -describe("writeRows", () => { - test("writes one line per project", () => { - const { writer, output } = createCapture(); - const projects = [ - makeProject({ - slug: "proj-a", - name: "Project A", - platform: "javascript", - orgSlug: "org1", - }), - makeProject({ - slug: "proj-b", - name: "Project B", - platform: "python", - orgSlug: "org2", - }), - ]; - writeRows({ - stdout: writer, - projects, - orgWidth: 10, - slugWidth: 15, - nameWidth: 20, - }); - const lines = output().split("\n").filter(Boolean); - expect(lines).toHaveLength(2); - }); -}); - describe("writeSelfHostedWarning", () => { test("writes nothing when skippedSelfHosted is undefined", () => { const { writer, output } = createCapture(); diff --git a/test/lib/formatters/human.details.test.ts b/test/lib/formatters/human.details.test.ts index cdbb9452..8951f3dd 100644 --- a/test/lib/formatters/human.details.test.ts +++ b/test/lib/formatters/human.details.test.ts @@ -7,16 +7,12 @@ import { describe, expect, test } from "bun:test"; import { - calculateOrgSlugWidth, - calculateProjectColumnWidths, formatEventDetails, formatFixability, formatFixabilityDetail, formatIssueDetails, formatOrgDetails, - formatOrgRow, formatProjectDetails, - formatProjectRow, getSeerFixabilityLabel, } from "../../../src/lib/formatters/human.js"; import type { @@ -86,40 +82,6 @@ function createMockIssue(overrides: Partial = {}): SentryIssue { // Organization Formatting Tests -describe("calculateOrgSlugWidth", () => { - test("returns minimum width of 4 for empty array", () => { - expect(calculateOrgSlugWidth([])).toBe(4); - }); - - test("returns max slug length when longer than minimum", () => { - const orgs = [ - createMockOrg({ slug: "ab" }), - createMockOrg({ slug: "longer-org-slug" }), - createMockOrg({ slug: "medium" }), - ]; - expect(calculateOrgSlugWidth(orgs)).toBe(15); // "longer-org-slug".length - }); - - test("returns minimum when all slugs are shorter", () => { - const orgs = [createMockOrg({ slug: "ab" }), createMockOrg({ slug: "xy" })]; - expect(calculateOrgSlugWidth(orgs)).toBe(4); // minimum is 4 - }); -}); - -describe("formatOrgRow", () => { - test("formats organization row with padding", () => { - const org = createMockOrg({ slug: "acme", name: "Acme Inc" }); - const result = formatOrgRow(org, 10); - expect(result).toBe("acme Acme Inc"); - }); - - test("handles long slug correctly", () => { - const org = createMockOrg({ slug: "very-long-org", name: "Test" }); - const result = formatOrgRow(org, 15); - expect(result).toBe("very-long-org Test"); - }); -}); - describe("formatOrgDetails", () => { test("formats basic organization details", () => { const org = createMockOrg({ @@ -173,94 +135,6 @@ describe("formatOrgDetails", () => { // Project Formatting Tests -describe("calculateProjectColumnWidths", () => { - test("returns minimum widths for empty array", () => { - const result = calculateProjectColumnWidths([]); - expect(result.orgWidth).toBe(3); - expect(result.slugWidth).toBe(7); - expect(result.nameWidth).toBe(4); - }); - - test("calculates widths based on content", () => { - const projects = [ - createMockProject({ - orgSlug: "acme-corp", - slug: "frontend-app", - name: "Frontend Application", - }), - createMockProject({ orgSlug: "beta", slug: "api", name: "API" }), - ]; - - const result = calculateProjectColumnWidths(projects); - - expect(result.orgWidth).toBe(9); // "acme-corp".length - expect(result.slugWidth).toBe(12); // "frontend-app".length - expect(result.nameWidth).toBe(20); // "Frontend Application".length - }); - - test("handles missing orgSlug", () => { - const projects = [ - createMockProject({ orgSlug: undefined, slug: "test", name: "Test" }), - ]; - - const result = calculateProjectColumnWidths(projects); - expect(result.orgWidth).toBe(3); // minimum - }); -}); - -describe("formatProjectRow", () => { - test("formats project row with all columns", () => { - const project = createMockProject({ - orgSlug: "acme", - slug: "frontend", - name: "Frontend", - platform: "javascript", - }); - - const result = formatProjectRow(project, { - orgWidth: 8, - slugWidth: 10, - nameWidth: 10, - }); - - expect(result).toBe("acme frontend Frontend javascript"); - }); - - test("handles missing platform", () => { - const project = createMockProject({ - orgSlug: "acme", - slug: "api", - name: "API", - platform: undefined, - }); - - const result = formatProjectRow(project, { - orgWidth: 5, - slugWidth: 5, - nameWidth: 5, - }); - - expect(result).toBe("acme api API "); - }); - - test("handles missing orgSlug", () => { - const project = createMockProject({ - orgSlug: undefined, - slug: "test", - name: "Test", - platform: "python", - }); - - const result = formatProjectRow(project, { - orgWidth: 5, - slugWidth: 5, - nameWidth: 5, - }); - - expect(result).toBe(" test Test python"); - }); -}); - describe("formatProjectDetails", () => { test("formats basic project details", () => { const project = createMockProject({ From 8b48be081804cab83d18539f6ff4b86df09784bb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 11:20:09 +0000 Subject: [PATCH 25/52] refactor(formatters): migrate issue list to markdown table via writeIssueTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace formatIssueRow/formatIssueListHeader with writeIssueTable() using Column[] + writeTable() pattern - Remove manual padEnd/padStart column alignment, wrapTitle, and all COL_* width constants — cli-table3 handles sizing automatically - Remove padding from formatRelativeTime and abbreviateCount since table renderer manages column alignment - ANSI-colored cell values (level, fixability, short ID highlights) survive the cli-table3 pipeline via string-width - Multi-project mode conditionally adds ALIAS column --- src/commands/issue/list.ts | 42 +-- src/lib/formatters/human.ts | 305 ++++++--------------- src/lib/formatters/trace.ts | 2 +- test/lib/formatters/human.property.test.ts | 31 --- test/lib/formatters/human.test.ts | 103 ++++--- 5 files changed, 167 insertions(+), 316 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 773890df..fb1f1a92 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -35,11 +35,9 @@ import { ValidationError, } from "../../lib/errors.js"; import { - divider, - type FormatShortIdOptions, - formatIssueListHeader, - formatIssueRow, + type IssueTableRow, muted, + writeIssueTable, writeJson, } from "../../lib/formatters/index.js"; import { @@ -111,14 +109,8 @@ function parseSort(value: string): SortValue { * @param title - Section title * @param isMultiProject - Whether to show ALIAS column for multi-project mode */ -function writeListHeader( - stdout: Writer, - title: string, - isMultiProject = false -): void { +function writeListHeader(stdout: Writer, title: string): void { stdout.write(`${title}:\n\n`); - stdout.write(muted(`${formatIssueListHeader(isMultiProject)}\n`)); - stdout.write(`${divider(isMultiProject ? 96 : 80)}\n`); } /** Issue with formatting options attached */ @@ -129,19 +121,6 @@ function writeListHeader( formatOptions: FormatShortIdOptions; }; -/** - * Write formatted issue rows to stdout. - */ -function writeIssueRows( - stdout: Writer, - issues: IssueWithOptions[], - termWidth: number -): void { - for (const { issue, formatOptions } of issues) { - stdout.write(`${formatIssueRow(issue, termWidth, formatOptions)}\n`); - } -} - /** * Write footer with usage tip. * @@ -234,7 +213,7 @@ function attachFormatOptions( results: IssueListResult[], aliasMap: Map, isMultiProject: boolean -): IssueWithOptions[] { +): IssueTableRow[] { return results.flatMap((result) => result.issues.map((issue) => { const key = `${result.target.org}/${result.target.project}`; @@ -797,9 +776,8 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise { // isMultiProject=true: org-all shows issues from every project, so the ALIAS // column is needed to identify which project each issue belongs to. - writeListHeader(stdout, `Issues in ${org}`, true); - const termWidth = process.stdout.columns || 80; - const issuesWithOpts = issues.map((issue) => ({ + writeListHeader(stdout, `Issues in ${org}`); + const issuesWithOpts: IssueTableRow[] = issues.map((issue) => ({ issue, // org-all: org context comes from the `org` param; issue.organization may be absent orgSlug: org, @@ -808,7 +786,7 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise { isMultiProject: true, }, })); - writeIssueRows(stdout, issuesWithOpts, termWidth); + writeIssueTable(stdout, issuesWithOpts, true); if (hasMore) { stdout.write(`\nShowing ${issues.length} issues (more available)\n`); @@ -1059,10 +1037,8 @@ async function handleResolvedTargets( ? `Issues in ${firstTarget.orgDisplay}/${firstTarget.projectDisplay}` : `Issues from ${validResults.length} projects`; - writeListHeader(stdout, title, isMultiProject); - - const termWidth = process.stdout.columns || 80; - writeIssueRows(stdout, issuesWithOptions, termWidth); + writeListHeader(stdout, title); + writeIssueTable(stdout, issuesWithOptions, isMultiProject); let footerMode: "single" | "multi" | "none" = "none"; if (isMultiProject) { diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 5ba14d71..64d49d58 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -22,6 +22,7 @@ import type { SentryProject, StackFrame, TraceSpan, + Writer, } from "../../types/index.js"; import { withSerializeSpan } from "../telemetry.js"; import { @@ -40,6 +41,7 @@ import { renderMarkdown, safeCodeSpan, } from "./markdown.js"; +import { type Column, writeTable } from "./table.js"; // Status Formatting @@ -209,7 +211,7 @@ export function formatStatusLabel(status: string | undefined): string { */ export function formatRelativeTime(dateString: string | undefined): string { if (!dateString) { - return muted("—").padEnd(10); + return muted("—"); } const date = new Date(dateString); @@ -231,163 +233,45 @@ export function formatRelativeTime(dateString: string | undefined): string { text = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } - return text.padEnd(10); + return text; } // Issue Formatting -/** Column widths for issue list table */ -const COL_LEVEL = 7; -const COL_ALIAS = 15; -const COL_SHORT_ID = 22; -const COL_COUNT = 5; -const COL_SEEN = 10; -/** Width for the FIXABILITY column (longest value "high(100%)" = 10) */ -const COL_FIX = 10; - /** Quantifier suffixes indexed by groups of 3 digits (K=10^3, M=10^6, …, E=10^18) */ const QUANTIFIERS = ["", "K", "M", "B", "T", "P", "E"]; /** - * Abbreviate large numbers to fit within {@link COL_COUNT} characters. - * Uses K/M/B/T/P/E suffixes up to 10^18 (exa). + * Abbreviate large numbers with K/M/B/T/P/E suffixes (up to 10^18). * * The decimal is only shown when the rounded value is < 100 (e.g. "12.3K", - * "1.5M" but not "100M"). The result is always exactly COL_COUNT chars wide. - * - * Note: `Number(raw)` loses precision above `Number.MAX_SAFE_INTEGER` - * (~9P / 9×10^15), which is far beyond any realistic Sentry event count. + * "1.5M" but not "100M"). * - * Examples: 999 → " 999", 12345 → "12.3K", 150000 → " 150K", 1500000 → "1.5M" + * @param raw - Stringified count + * @returns Abbreviated string without padding */ function abbreviateCount(raw: string): string { const n = Number(raw); if (Number.isNaN(n)) { - // Non-numeric input: use a placeholder rather than passing through an - // arbitrarily wide string that would break column alignment Sentry.logger.warn(`Unexpected non-numeric issue count: ${raw}`); - return "?".padStart(COL_COUNT); + return "?"; } - if (raw.length <= COL_COUNT) { - return raw.padStart(COL_COUNT); + if (n < 1000) { + return raw; } const tier = Math.min(Math.floor(Math.log10(n) / 3), QUANTIFIERS.length - 1); const suffix = QUANTIFIERS[tier] ?? ""; const scaled = n / 10 ** (tier * 3); - // Only show decimal when it adds information — compare the rounded value to avoid - // "100.0K" when scaled is e.g. 99.95 (toFixed(1) rounds up to "100.0") const rounded1dp = Number(scaled.toFixed(1)); if (rounded1dp < 100) { - return `${rounded1dp.toFixed(1)}${suffix}`.padStart(COL_COUNT); + return `${rounded1dp.toFixed(1)}${suffix}`; } const rounded = Math.round(scaled); - // Promote to next tier if rounding produces >= 1000 (e.g. 999.95K → "1.0M") if (rounded >= 1000 && tier < QUANTIFIERS.length - 1) { const nextSuffix = QUANTIFIERS[tier + 1] ?? ""; - return `${(rounded / 1000).toFixed(1)}${nextSuffix}`.padStart(COL_COUNT); - } - // At max tier with no promotion available: cap at 999 to guarantee COL_COUNT width - // (numbers > 10^21 are unreachable in practice for Sentry event counts) - return `${Math.min(rounded, 999)}${suffix}`.padStart(COL_COUNT); -} - -/** Column where title starts in single-project mode (no ALIAS column) */ -const TITLE_START_COL = - COL_LEVEL + 1 + COL_SHORT_ID + 1 + COL_COUNT + 2 + COL_SEEN + 2 + COL_FIX + 2; - -/** Column where title starts in multi-project mode (with ALIAS column) */ -const TITLE_START_COL_MULTI = - COL_LEVEL + - 1 + - COL_ALIAS + - 1 + - COL_SHORT_ID + - 1 + - COL_COUNT + - 2 + - COL_SEEN + - 2 + - COL_FIX + - 2; - -/** - * Format the header row for issue list table. - * Uses same column widths as data rows to ensure alignment. - * - * @param isMultiProject - Whether to include ALIAS column for multi-project mode - */ -export function formatIssueListHeader(isMultiProject = false): string { - if (isMultiProject) { - return ( - "LEVEL".padEnd(COL_LEVEL) + - " " + - "ALIAS".padEnd(COL_ALIAS) + - " " + - "SHORT ID".padEnd(COL_SHORT_ID) + - " " + - "COUNT".padStart(COL_COUNT) + - " " + - "SEEN".padEnd(COL_SEEN) + - " " + - "FIXABILITY".padEnd(COL_FIX) + - " " + - "TITLE" - ); + return `${(rounded / 1000).toFixed(1)}${nextSuffix}`; } - return ( - "LEVEL".padEnd(COL_LEVEL) + - " " + - "SHORT ID".padEnd(COL_SHORT_ID) + - " " + - "COUNT".padStart(COL_COUNT) + - " " + - "SEEN".padEnd(COL_SEEN) + - " " + - "FIXABILITY".padEnd(COL_FIX) + - " " + - "TITLE" - ); -} - -/** - * Wrap long text with indentation for continuation lines. - * Breaks at word boundaries when possible. - * - * @param text - Text to wrap - * @param startCol - Column where text starts (for indenting continuation lines) - * @param termWidth - Terminal width - */ -function wrapTitle(text: string, startCol: number, termWidth: number): string { - const availableWidth = termWidth - startCol; - - // No wrapping needed or terminal too narrow - if (text.length <= availableWidth || availableWidth < 20) { - return text; - } - - const indent = " ".repeat(startCol); - const lines: string[] = []; - let remaining = text; - - while (remaining.length > 0) { - if (remaining.length <= availableWidth) { - lines.push(remaining); - break; - } - - // Find break point (prefer word boundary) - let breakAt = availableWidth; - const lastSpace = remaining.lastIndexOf(" ", availableWidth); - if (lastSpace > availableWidth * 0.5) { - breakAt = lastSpace; - } - - lines.push(remaining.slice(0, breakAt).trimEnd()); - remaining = remaining.slice(breakAt).trimStart(); - } - - // First line has no indent, continuation lines do - return lines.join(`\n${indent}`); + return `${Math.min(rounded, 999)}${suffix}`; } /** @@ -406,42 +290,24 @@ export type FormatShortIdOptions = { * Format short ID for multi-project mode by highlighting the alias characters. * Only highlights the specific characters that form the alias: * - CLI-25 with alias "c" → **C**LI-**25** - * - CLI-WEBSITE-4 with alias "w" → CLI-**W**EBSITE-**4** - * - API-APP-5 with alias "ap" → API-**AP**P-**5** (searches backwards to find correct part) - * - X-AB-5 with alias "x-a" → **X-A**B-**5** (handles aliases with embedded dashes) * - * @returns Formatted string if alias matches, null otherwise (to fall back to default) + * @returns Formatted string with ANSI highlights, or null if no match found */ function formatShortIdWithAlias( - upperShortId: string, + shortId: string, projectAlias: string ): string | null { - // Extract project part of alias (handle "o1/d" format for collision cases) - const aliasProjectPart = projectAlias.includes("/") - ? projectAlias.split("/").pop() - : projectAlias; - - if (!aliasProjectPart) { - return null; - } - - const parts = upperShortId.split("-"); - if (parts.length < 2) { - return null; - } - - const aliasUpper = aliasProjectPart.toUpperCase(); + const aliasUpper = projectAlias.toUpperCase(); const aliasLen = aliasUpper.length; - const projectParts = parts.slice(0, -1); - const issueSuffix = parts.at(-1) ?? ""; - // Method 1: For aliases without dashes, search backwards through project parts - // This handles cases like "api-app" where alias "ap" should match "APP" not "API" + const parts = shortId.split("-"); + const issueSuffix = parts.pop() ?? ""; + const projectParts = parts; + if (!aliasUpper.includes("-")) { for (let i = projectParts.length - 1; i >= 0; i--) { const part = projectParts[i]; if (part?.startsWith(aliasUpper)) { - // Found match - highlight alias prefix in this part and the issue suffix const result = projectParts.map((p, idx) => { if (idx === i) { return boldUnderline(p.slice(0, aliasLen)) + p.slice(aliasLen); @@ -453,11 +319,8 @@ function formatShortIdWithAlias( } } - // Method 2: For aliases with dashes (or if Method 1 found no match), - // match against the joined project portion const projectPortion = projectParts.join("-"); if (projectPortion.startsWith(aliasUpper)) { - // Highlight first aliasLen chars of project portion, plus issue suffix const highlighted = boldUnderline(projectPortion.slice(0, aliasLen)); const rest = projectPortion.slice(aliasLen); return `${highlighted}${rest}-${boldUnderline(issueSuffix)}`; @@ -473,21 +336,19 @@ function formatShortIdWithAlias( * - Multi-project: CLI-WEBSITE-4 with alias "w" → CLI-**W**EBSITE-**4** (alias chars highlighted) * * @param shortId - Full short ID (e.g., "CLI-25", "CLI-WEBSITE-4") - * @param options - Formatting options (projectSlug, projectAlias, isMultiProject) + * @param options - Formatting options (projectSlug and/or projectAlias) * @returns Formatted short ID with highlights */ export function formatShortId( shortId: string, options?: FormatShortIdOptions | string ): string { - // Handle legacy string parameter (projectSlug only) const opts: FormatShortIdOptions = typeof options === "string" ? { projectSlug: options } : (options ?? {}); const { projectSlug, projectAlias, isMultiProject } = opts; const upperShortId = shortId.toUpperCase(); - // In multi-project mode with an alias, highlight the part that the alias represents if (isMultiProject && projectAlias) { const formatted = formatShortIdWithAlias(upperShortId, projectAlias); if (formatted) { @@ -495,7 +356,6 @@ export function formatShortId( } } - // Single-project mode or fallback: highlight just the issue suffix if (projectSlug) { const prefix = `${projectSlug.toUpperCase()}-`; if (upperShortId.startsWith(prefix)) { @@ -507,14 +367,6 @@ export function formatShortId( return upperShortId; } -/** - * Calculate the raw display length of a formatted short ID (without ANSI codes). - * In all modes, we display the full shortId (just with different styling). - */ -function getShortIdDisplayLength(shortId: string): number { - return shortId.length; -} - /** * Compute the alias shorthand for an issue (e.g., "o1:d-a3", "w-2a"). * This is what users type to reference the issue. @@ -531,60 +383,77 @@ function computeAliasShorthand(shortId: string, projectAlias?: string): string { return `${projectAlias}-${suffix}`; } +/** Row data prepared for the issue table */ +export type IssueTableRow = { + issue: SentryIssue; + formatOptions: FormatShortIdOptions; +}; + /** - * Format a single issue for list display. - * Wraps long titles with proper indentation. + * Write an issue list as a Unicode-bordered markdown table. * - * @param issue - Issue to format - * @param termWidth - Terminal width for wrapping (default 80) - * @param shortIdOptions - Options for formatting the short ID (projectSlug and/or projectAlias) + * Columns are conditionally included based on `isMultiProject` mode. + * Cell values are pre-colored with ANSI codes which survive the + * cli-table3 rendering pipeline. + * + * @param stdout - Output writer + * @param rows - Issues with formatting options + * @param isMultiProject - Whether to include the ALIAS column */ -export function formatIssueRow( - issue: SentryIssue, - termWidth = 80, - shortIdOptions?: FormatShortIdOptions | string -): string { - // Handle legacy string parameter (projectSlug only) - const opts: FormatShortIdOptions = - typeof shortIdOptions === "string" - ? { projectSlug: shortIdOptions } - : (shortIdOptions ?? {}); - - const { isMultiProject, projectAlias } = opts; - - const levelText = (issue.level ?? "unknown").toUpperCase().padEnd(COL_LEVEL); - const level = levelColor(levelText, issue.level); - const formattedShortId = formatShortId(issue.shortId, opts); - - // Calculate raw display length (without ANSI codes) for padding - const rawLen = getShortIdDisplayLength(issue.shortId); - const shortIdPadding = " ".repeat(Math.max(0, COL_SHORT_ID - rawLen)); - const shortId = `${formattedShortId}${shortIdPadding}`; - const count = abbreviateCount(`${issue.count}`); - const seen = formatRelativeTime(issue.lastSeen); - - // Fixability column (color applied after padding to preserve alignment) - const fixText = formatFixability(issue.seerFixabilityScore); - const fixPadding = " ".repeat(Math.max(0, COL_FIX - fixText.length)); - const score = issue.seerFixabilityScore; - const fix = - fixText && score !== null && score !== undefined - ? fixabilityColor(fixText, getSeerFixabilityLabel(score)) + fixPadding - : fixPadding; - - // Multi-project mode: include ALIAS column +export function writeIssueTable( + stdout: Writer, + rows: IssueTableRow[], + isMultiProject: boolean +): void { + const columns: Column[] = [ + { + header: "LEVEL", + value: ({ issue }) => + levelColor((issue.level ?? "unknown").toUpperCase(), issue.level), + }, + ]; + if (isMultiProject) { - const aliasShorthand = computeAliasShorthand(issue.shortId, projectAlias); - const aliasPadding = " ".repeat( - Math.max(0, COL_ALIAS - aliasShorthand.length) - ); - const alias = `${aliasShorthand}${aliasPadding}`; - const title = wrapTitle(issue.title, TITLE_START_COL_MULTI, termWidth); - return `${level} ${alias} ${shortId} ${count} ${seen} ${fix} ${title}`; + columns.push({ + header: "ALIAS", + value: ({ issue, formatOptions }) => + computeAliasShorthand(issue.shortId, formatOptions.projectAlias), + }); } - const title = wrapTitle(issue.title, TITLE_START_COL, termWidth); - return `${level} ${shortId} ${count} ${seen} ${fix} ${title}`; + columns.push( + { + header: "SHORT ID", + value: ({ issue, formatOptions }) => + formatShortId(issue.shortId, formatOptions), + }, + { + header: "COUNT", + value: ({ issue }) => abbreviateCount(`${issue.count}`), + align: "right", + }, + { + header: "SEEN", + value: ({ issue }) => formatRelativeTime(issue.lastSeen), + }, + { + header: "FIXABILITY", + value: ({ issue }) => { + const text = formatFixability(issue.seerFixabilityScore); + const score = issue.seerFixabilityScore; + if (text && score !== null && score !== undefined) { + return fixabilityColor(text, getSeerFixabilityLabel(score)); + } + return ""; + }, + }, + { + header: "TITLE", + value: ({ issue }) => issue.title, + } + ); + + writeTable(stdout, rows, columns); } /** diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index aa5ef61d..c70ce4ac 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -65,7 +65,7 @@ function buildTraceRowCells( `\`${item.trace}\``, escapeMarkdownCell(item.transaction || "unknown"), formatTraceDuration(item["transaction.duration"]), - formatRelativeTime(item.timestamp).trim(), + formatRelativeTime(item.timestamp), ]; } diff --git a/test/lib/formatters/human.property.test.ts b/test/lib/formatters/human.property.test.ts index bf69810e..11423805 100644 --- a/test/lib/formatters/human.property.test.ts +++ b/test/lib/formatters/human.property.test.ts @@ -18,7 +18,6 @@ import { import { formatFixability, formatFixabilityDetail, - formatIssueListHeader, formatShortId, formatUserIdentity, getSeerFixabilityLabel, @@ -240,36 +239,6 @@ describe("formatUserIdentity properties", () => { }); }); -describe("formatIssueListHeader properties", () => { - test("multi-project mode always includes ALIAS column", () => { - const header = formatIssueListHeader(true); - expect(header).toContain("ALIAS"); - }); - - test("single-project mode never includes ALIAS column", () => { - const header = formatIssueListHeader(false); - expect(header).not.toContain("ALIAS"); - }); - - test("both modes include essential columns", async () => { - const essentialColumns = [ - "LEVEL", - "SHORT ID", - "COUNT", - "SEEN", - "FIXABILITY", - "TITLE", - ]; - - for (const isMultiProject of [true, false]) { - const header = formatIssueListHeader(isMultiProject); - for (const col of essentialColumns) { - expect(header).toContain(col); - } - } - }); -}); - // Fixability Formatting Properties /** Score in valid API range [0, 1] */ diff --git a/test/lib/formatters/human.test.ts b/test/lib/formatters/human.test.ts index 3cf17165..1724ab11 100644 --- a/test/lib/formatters/human.test.ts +++ b/test/lib/formatters/human.test.ts @@ -8,9 +8,10 @@ import { describe, expect, test } from "bun:test"; import { - formatIssueRow, formatShortId, formatUserIdentity, + type IssueTableRow, + writeIssueTable, } from "../../../src/lib/formatters/human.js"; import type { SentryIssue } from "../../../src/types/index.js"; @@ -146,7 +147,7 @@ describe("formatShortId multi-project alias highlighting", () => { }); }); -describe("formatIssueRow", () => { +describe("writeIssueTable", () => { const mockIssue: SentryIssue = { id: "123", shortId: "DASHBOARD-A3", @@ -160,43 +161,79 @@ describe("formatIssueRow", () => { permalink: "https://sentry.io/issues/123", }; - test("single project mode does not include alias column", () => { - const row = formatIssueRow(mockIssue, 80, { - projectSlug: "dashboard", - }); - // Should not have alias shorthand format - expect(stripAnsi(row)).not.toContain("o1:d-a3"); + function capture(): { + writer: { write: (s: string) => boolean }; + output: () => string; + } { + let buf = ""; + return { + writer: { + write: (s: string) => { + buf += s; + return true; + }, + }, + output: () => buf, + }; + } + + test("single project mode does not include ALIAS column", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { issue: mockIssue, formatOptions: { projectSlug: "dashboard" } }, + ]; + writeIssueTable(writer, rows, false); + const text = stripAnsi(output()); + expect(text).not.toContain("ALIAS"); + expect(text).toContain("DASHBOARD-A3"); + expect(text).toContain("Test issue"); }); - test("multi-project mode includes alias column", () => { - const row = formatIssueRow(mockIssue, 120, { - projectSlug: "dashboard", - projectAlias: "o1:d", - isMultiProject: true, - }); - // Should contain the alias shorthand - expect(stripAnsi(row)).toContain("o1:d-a3"); + test("multi-project mode includes ALIAS column with alias shorthand", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { + issue: mockIssue, + formatOptions: { + projectSlug: "dashboard", + projectAlias: "o1:d", + isMultiProject: true, + }, + }, + ]; + writeIssueTable(writer, rows, true); + const text = stripAnsi(output()); + expect(text).toContain("ALIAS"); + expect(text).toContain("o1:d-a3"); }); - test("alias shorthand is lowercase", () => { - const row = formatIssueRow(mockIssue, 120, { - projectSlug: "dashboard", - projectAlias: "o1:d", - isMultiProject: true, - }); - // The alias shorthand should be lowercase - expect(stripAnsi(row)).toContain("o1:d-a3"); - expect(stripAnsi(row)).not.toContain("O1:D-A3"); + test("table contains all essential columns", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { issue: mockIssue, formatOptions: { projectSlug: "dashboard" } }, + ]; + writeIssueTable(writer, rows, false); + const text = stripAnsi(output()); + for (const col of [ + "LEVEL", + "SHORT ID", + "COUNT", + "SEEN", + "FIXABILITY", + "TITLE", + ]) { + expect(text).toContain(col); + } }); - test("unique alias format works in multi-project mode", () => { - const row = formatIssueRow(mockIssue, 120, { - projectSlug: "dashboard", - projectAlias: "d", - isMultiProject: true, - }); - // Should contain simple alias shorthand - expect(stripAnsi(row)).toContain("d-a3"); + test("level and title values appear in output", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [{ issue: mockIssue, formatOptions: {} }]; + writeIssueTable(writer, rows, false); + const text = stripAnsi(output()); + expect(text).toContain("ERROR"); + expect(text).toContain("Test issue"); + expect(text).toContain("42"); }); }); From cecc46c40ffbd4bb68ca52c8ff667653b98f6830 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 12:26:51 +0000 Subject: [PATCH 26/52] refactor(formatters): replace marked-terminal with custom renderer + OpenTUI table engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the marked-terminal dependency chain (~970KB: cli-highlight, node-emoji, cli-table3, parse5) with a custom markdown-to-ANSI renderer that walks marked tokens directly. Table rendering uses column fitting algorithms ported from OpenTUI's TextTable (balanced/proportional shrink with sqrt-weighting) and Unicode box-drawing borders. The text-table module provides ANSI-aware column sizing via string-width and word wrapping via wrap-ansi. Syntax highlighting in code blocks preserved via cli-highlight (direct dependency, no longer through marked-terminal's HTML round-trip). - New: src/lib/formatters/border.ts — box-drawing character definitions ported from OpenTUI - New: src/lib/formatters/text-table.ts — column fitter + table renderer - Rewritten: src/lib/formatters/markdown.ts — custom token renderer - Updated: table.ts — renders directly via text-table in TTY mode - Removed: marked-terminal, @types/marked-terminal, node-emoji esbuild stub --- bun.lock | 65 ++--- package.json | 5 +- script/bundle.ts | 27 +- src/lib/formatters/border.ts | 83 ++++++ src/lib/formatters/markdown.ts | 460 ++++++++++++++++++------------ src/lib/formatters/table.ts | 38 ++- src/lib/formatters/text-table.ts | 472 +++++++++++++++++++++++++++++++ 7 files changed, 885 insertions(+), 265 deletions(-) create mode 100644 src/lib/formatters/border.ts create mode 100644 src/lib/formatters/text-table.ts diff --git a/bun.lock b/bun.lock index 4dc6b250..4a4cf592 100644 --- a/bun.lock +++ b/bun.lock @@ -13,25 +13,26 @@ "@stricli/auto-complete": "^1.2.4", "@stricli/core": "^1.2.4", "@types/bun": "latest", - "@types/marked-terminal": "^6.1.1", "@types/node": "^22", "@types/qrcode-terminal": "^0.12.2", "@types/semver": "^7.7.1", "binpunch": "^1.0.0", "chalk": "^5.6.2", + "cli-highlight": "^2.1.11", "esbuild": "^0.25.0", "fast-check": "^4.5.3", "ignore": "^7.0.5", "marked": "^15", - "marked-terminal": "^7.3.0", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", "semver": "^7.7.3", + "string-width": "^8.2.0", "tinyglobby": "^0.2.15", "typescript": "^5", "ultracite": "6.3.10", "uuidv7": "^1.1.0", + "wrap-ansi": "^10.0.0", "zod": "^3.24.0", }, }, @@ -98,8 +99,6 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], - "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -266,8 +265,6 @@ "@sentry/opentelemetry": ["@sentry/opentelemetry@10.39.0", "", { "dependencies": { "@sentry/core": "10.39.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-eU8t/pyxjy7xYt6PNCVxT+8SJw5E3pnupdcUNN4ClqG4O5lX4QCDLtId48ki7i30VqrLtR7vmCHMSvqXXdvXPA=="], - "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], - "@stricli/auto-complete": ["@stricli/auto-complete@1.2.5", "", { "dependencies": { "@stricli/core": "^1.2.5" }, "bin": { "auto-complete": "dist/bin/cli.js" } }, "sha512-C6G88Hh4lUWBwiqsxbcA4I1ricSQwiLaOziTWW3NmBoX7WGTW7i7RvyooXMpZk1YMLf2olv5Odxmg127ik1DKQ=="], "@stricli/core": ["@stricli/core@1.2.5", "", {}, "sha512-+afyztQW7fwWkqmU2WQZbdc3LjnZThWYdtE0l+hykZ1Rvy7YGxZSvsVCS/wZ/2BNv117pQ9TU1GZZRIcPnB4tw=="], @@ -276,12 +273,8 @@ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], - "@types/cardinal": ["@types/cardinal@2.1.1", "", {}, "sha512-/xCVwg8lWvahHsV2wXZt4i64H1sdL+sN1Uoq7fAc8/FA6uYHjuIveDwPwvGUYp4VZiv85dVl6J/Bum3NDAOm8g=="], - "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], - "@types/marked-terminal": ["@types/marked-terminal@6.1.1", "", { "dependencies": { "@types/cardinal": "^2.1", "@types/node": "*", "chalk": "^5.3.0", "marked": ">=6.0.0 <12" } }, "sha512-DfoUqkmFDCED7eBY9vFUhJ9fW8oZcMAK5EwRDQ9drjTbpQa+DnBTQQCwWhTFVf4WsZ6yYcJTI8D91wxTWXRZZQ=="], - "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], @@ -302,11 +295,9 @@ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], @@ -332,8 +323,6 @@ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], - "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], @@ -342,8 +331,6 @@ "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], - "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], - "cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -364,10 +351,6 @@ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], - - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -390,6 +373,8 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -432,8 +417,6 @@ "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], - "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], @@ -444,8 +427,6 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], - "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -510,16 +491,12 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], - - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -540,8 +517,6 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], - "unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -560,7 +535,7 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], @@ -586,14 +561,18 @@ "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], - "@types/marked-terminal/marked": ["marked@11.2.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw=="], - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "bun-types/@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -602,10 +581,10 @@ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ultracite/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="], "@prisma/instrumentation/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.5", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-0InH9/4oDCBRzWXhpOqusspLBrVfK1vPvbn9Wxl8DAQ8yyx5fWJRETICSwkiAMaYntjJAMBP1R4B6cQnEUYVEA=="], @@ -616,10 +595,20 @@ "@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index f07fcff4..6fdaca14 100644 --- a/package.json +++ b/package.json @@ -14,25 +14,26 @@ "@stricli/auto-complete": "^1.2.4", "@stricli/core": "^1.2.4", "@types/bun": "latest", - "@types/marked-terminal": "^6.1.1", "@types/node": "^22", "@types/qrcode-terminal": "^0.12.2", "@types/semver": "^7.7.1", "binpunch": "^1.0.0", "chalk": "^5.6.2", + "cli-highlight": "^2.1.11", "esbuild": "^0.25.0", "fast-check": "^4.5.3", "ignore": "^7.0.5", "marked": "^15", - "marked-terminal": "^7.3.0", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", "semver": "^7.7.3", + "string-width": "^8.2.0", "tinyglobby": "^0.2.15", "typescript": "^5", "ultracite": "6.3.10", "uuidv7": "^1.1.0", + "wrap-ansi": "^10.0.0", "zod": "^3.24.0" }, "bin": { diff --git a/script/bundle.ts b/script/bundle.ts index 2b9f4eb2..d50b8143 100644 --- a/script/bundle.ts +++ b/script/bundle.ts @@ -31,7 +31,6 @@ if (!SENTRY_CLIENT_ID) { // Regex patterns for esbuild plugin (must be top-level for performance) const BUN_SQLITE_FILTER = /^bun:sqlite$/; -const NODE_EMOJI_FILTER = /^node-emoji$/; const ANY_FILTER = /.*/; // Plugin to replace bun:sqlite with our node:sqlite polyfill @@ -60,32 +59,8 @@ const bunSqlitePlugin: Plugin = { }, }; -/** - * Plugin to stub out `node-emoji` — a 208KB emoji library that - * `marked-terminal` statically imports but we never use (emoji: false). - * The static `import * as emoji from 'node-emoji'` prevents tree-shaking, - * so we replace it with a no-op stub at build time. - */ -const nodeEmojiStubPlugin: Plugin = { - name: "node-emoji-stub", - setup(pluginBuild) { - pluginBuild.onResolve({ filter: NODE_EMOJI_FILTER }, () => ({ - path: "node-emoji", - namespace: "node-emoji-stub", - })); - - pluginBuild.onLoad( - { filter: ANY_FILTER, namespace: "node-emoji-stub" }, - () => ({ - contents: "export function get(s) { return s; }", - loader: "js", - }) - ); - }, -}; - // Configure Sentry plugin for source map uploads (production builds only) -const plugins: Plugin[] = [bunSqlitePlugin, nodeEmojiStubPlugin]; +const plugins: Plugin[] = [bunSqlitePlugin]; if (process.env.SENTRY_AUTH_TOKEN) { console.log(" Sentry auth token found, source maps will be uploaded"); diff --git a/src/lib/formatters/border.ts b/src/lib/formatters/border.ts new file mode 100644 index 00000000..c7f3748f --- /dev/null +++ b/src/lib/formatters/border.ts @@ -0,0 +1,83 @@ +/** + * Box-drawing border characters for table rendering. + * + * Ported from OpenTUI's border.ts with the styles relevant to CLI output. + * Each style defines 11 Unicode box-drawing characters for constructing + * grid borders around table cells. + * + * @see https://github.com/anomalyco/opentui/blob/main/packages/core/src/lib/border.ts + */ + +/** Complete set of box-drawing characters for a border style. */ +export type BorderCharacters = { + topLeft: string; + topRight: string; + bottomLeft: string; + bottomRight: string; + horizontal: string; + vertical: string; + topT: string; + bottomT: string; + leftT: string; + rightT: string; + cross: string; +}; + +/** Available border styles. */ +export type BorderStyle = "single" | "rounded" | "heavy" | "double"; + +/** Border character lookup table indexed by style. */ +export const BorderChars: Record = { + single: { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│", + topT: "┬", + bottomT: "┴", + leftT: "├", + rightT: "┤", + cross: "┼", + }, + rounded: { + topLeft: "╭", + topRight: "╮", + bottomLeft: "╰", + bottomRight: "╯", + horizontal: "─", + vertical: "│", + topT: "┬", + bottomT: "┴", + leftT: "├", + rightT: "┤", + cross: "┼", + }, + heavy: { + topLeft: "┏", + topRight: "┓", + bottomLeft: "┗", + bottomRight: "┛", + horizontal: "━", + vertical: "┃", + topT: "┳", + bottomT: "┻", + leftT: "┣", + rightT: "┫", + cross: "╋", + }, + double: { + topLeft: "╔", + topRight: "╗", + bottomLeft: "╚", + bottomRight: "╝", + horizontal: "═", + vertical: "║", + topT: "╦", + bottomT: "╩", + leftT: "╠", + rightT: "╣", + cross: "╬", + }, +}; diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 20fbe35e..93dbb9e3 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -1,14 +1,17 @@ /** * Markdown-to-Terminal Renderer * - * Central utility for rendering markdown content as styled terminal output - * using `marked` + `marked-terminal`. Provides `renderMarkdown()` and - * `renderInlineMarkdown()` for rich text output, with automatic plain-mode - * fallback when stdout is not a TTY or the user has opted out of rich output. + * Custom renderer that walks `marked` tokens and produces ANSI-styled + * terminal output using `chalk`. Replaces `marked-terminal` to eliminate + * its ~970KB dependency chain (cli-highlight, node-emoji, cli-table3, + * parse5) while giving us full control over table rendering. * - * Pre-rendered ANSI escape codes embedded in markdown source (e.g. inside - * table cells) survive the pipeline — `cli-table3` computes column widths - * via `string-width`, which correctly treats ANSI codes as zero-width. + * Table rendering delegates to the text-table module which uses + * OpenTUI-inspired column fitting algorithms and Unicode box-drawing + * borders. + * + * Pre-rendered ANSI escape codes embedded in markdown source are preserved + * — `string-width` correctly treats them as zero-width. * * ## Output mode resolution (highest → lowest priority) * @@ -21,64 +24,12 @@ */ import chalk from "chalk"; -import { type MarkedExtension, marked } from "marked"; -import { markedTerminal as _markedTerminal } from "marked-terminal"; +import { highlight as cliHighlight } from "cli-highlight"; +import { marked, type Token, type Tokens } from "marked"; import { muted } from "./colors.js"; +import { type Alignment, renderTextTable } from "./text-table.js"; -// @types/marked-terminal@6 describes the legacy class-based API; the package's -// actual markedTerminal() returns a {renderer, useNewRenderer} MarkedExtension -// object compatible with marked@15's marked.use(). -const markedTerminal = _markedTerminal as unknown as ( - options?: Parameters[0] -) => MarkedExtension; - -/** Sentinel-inspired color palette (mirrors colors.ts) */ -const COLORS = { - red: "#fe4144", - green: "#83da90", - yellow: "#FDB81B", - blue: "#226DFC", - cyan: "#79B8FF", - muted: "#898294", -} as const; - -marked.use( - markedTerminal({ - // Map markdown elements to our Sentinel palette - code: chalk.hex(COLORS.yellow), - blockquote: chalk.hex(COLORS.muted).italic, - heading: chalk.hex(COLORS.cyan).bold, - firstHeading: chalk.hex(COLORS.cyan).bold, - hr: chalk.hex(COLORS.muted), - listitem: chalk.reset, - table: chalk.reset, - paragraph: chalk.reset, - strong: chalk.bold, - em: chalk.italic, - codespan: chalk.hex(COLORS.yellow), - del: chalk.dim.gray.strikethrough, - link: chalk.hex(COLORS.blue), - href: chalk.hex(COLORS.blue).underline, - - // No "§ " section prefix before headings - showSectionPrefix: false, - - // Standard 80-column width; no reflow (let terminal wrap naturally) - width: 80, - reflowText: false, - - // Unescape HTML entities produced by the markdown parser - unescape: true, - - // Disabled — we never use emoji shortcodes, and node-emoji pulls in - // emojilib (208KB) which marked-terminal statically imports regardless - // of this setting. The npm bundle stubs it out via esbuild plugin. - emoji: false, - - // Two-space tabs for code blocks - tab: 2, - }) -); +// ──────────────────────────── Environment ───────────────────────────── /** * Returns true if an env var value should be treated as "truthy" for @@ -121,38 +72,23 @@ export function isPlainOutput(): boolean { return !process.stdout.isTTY; } +// ──────────────────────────── Escape helpers ────────────────────────── + /** * Escape a string for safe use inside a markdown table cell. * - * - Escapes backslashes first (so the escape character itself is not - * double-interpreted) - * - Escapes pipe characters (the table cell delimiter) - * - Replaces newlines with a space so multi-line values don't break the - * single-row structure of a markdown table - * - * @param value - Raw cell content - * @returns Markdown-safe string suitable for embedding in `| cell |` syntax + * Collapses newlines, escapes backslashes, then pipes. */ export function escapeMarkdownCell(value: string): string { return value.replace(/\n/g, " ").replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); } /** - * Escape CommonMark inline emphasis characters in a string for safe use inside - * markdown headings, blockquotes, and other inline contexts. - * - * This prevents characters like `_` (emphasis trigger) and `*` (strong trigger) - * in user-supplied content from being consumed by the markdown parser. - * For example, a Python exception title `TypeError in __init__` would render as - * `TypeError in init` (underscores consumed as empty emphasis) without escaping. + * Escape CommonMark inline emphasis characters. * - * Escaped characters: `\`, `*`, `_`, `` ` ``, `[`, `]`. - * - * @param value - Raw text (e.g. issue title, exception message) - * @returns String safe for inline use inside a marked rendering context + * Prevents `_`, `*`, `` ` ``, `[`, `]` from being consumed by the parser. */ export function escapeMarkdownInline(value: string): string { - // Escape backslash first so we don't double-escape subsequent substitutions return value .replace(/\\/g, "\\\\") .replace(/\*/g, "\\*") @@ -162,17 +98,20 @@ export function escapeMarkdownInline(value: string): string { .replace(/\]/g, "\\]"); } +/** + * Wrap a string in a backtick code span, sanitising characters that + * would break the span or surrounding table structure. + */ +export function safeCodeSpan(value: string): string { + return `\`${value.replace(/`/g, "\u02CB").replace(/\|/g, "\u2502").replace(/\n/g, " ")}\``; +} + +// ──────────────────────── Streaming table helpers ───────────────────── + /** * Build a raw markdown table header row + separator from column names. * - * Column names ending with `:` are right-aligned (the `:` is stripped from - * the displayed name and a `---:` separator is emitted instead of `---`). - * - * Used by batch-rendered tables that pipe the result through `renderMarkdown()`. - * For streaming table rows use {@link mdRow}. - * - * @param cols - Column names (append `:` for right-align, e.g. `"Duration:"`) - * @returns Two-line string: `| A | B |\n| --- | ---: |` + * Column names ending with `:` are right-aligned (the `:` is stripped). */ export function mdTableHeader(cols: readonly string[]): string { const names = cols.map((c) => (c.endsWith(":") ? c.slice(0, -1) : c)); @@ -181,85 +120,26 @@ export function mdTableHeader(cols: readonly string[]): string { } /** - * Build a markdown table row from cell values. - * - * In plain mode the cells are emitted as-is (raw CommonMark), so callers - * should pre-escape pipe characters via {@link escapeMarkdownCell}. - * - * In rendered mode each cell is passed through `renderInlineMarkdown()` so - * inline constructs like `**bold**` and `` `code` `` become ANSI-styled. - * After rendering, any remaining `|` characters (including those that - * `marked.parseInline` unescapes from CommonMark `\|` escapes) are replaced - * with `│` (U+2502, BOX DRAWINGS LIGHT VERTICAL) so they do not visually - * corrupt the pipe-delimited column structure in the terminal output. - * - * @param cells - Cell values (may contain inline markdown or escaped pipes) - * @returns `| a | b |\n` + * Build a streaming markdown table row. In plain mode emits raw markdown; + * in rendered mode applies inline styling and replaces `|` with `│`. */ export function mdRow(cells: readonly string[]): string { if (isPlainOutput()) { return `| ${cells.join(" | ")} |\n`; } - const out = cells.map((c) => renderInlineMarkdown(c).replace(/\|/g, "│")); + const out = cells.map((c) => + renderInline(marked.lexer(c).flatMap(flattenInline)).replace( + /\|/g, + "\u2502" + ) + ); return `| ${out.join(" | ")} |\n`; } -/** - * Wrap a user-supplied string in a backtick code span. - * - * Inside a CommonMark code span, backslashes are **literal** (not escape - * characters), so backslash-based escaping of special characters does not - * work. This helper sanitises the three characters that would otherwise break - * the span or the surrounding table structure: - * - * - Replaces `` ` `` with `ˋ` (U+02CB MODIFIER LETTER GRAVE ACCENT) — - * visually identical in monospace fonts but never treated as a code-span - * delimiter. Prevents exception messages like `Unexpected token \`` from - * prematurely closing the span. - * - Replaces `|` with `│` (U+2502 BOX DRAWINGS LIGHT VERTICAL) — prevents - * table column splitting when used inside a markdown table cell. - * - Replaces newlines with a space — code spans cannot span multiple lines. - * - * @param value - Raw string to format as a code span - * @returns `` `value` `` with backtick, pipe, and newline characters sanitised - */ -export function safeCodeSpan(value: string): string { - return `\`${value.replace(/`/g, "ˋ").replace(/\|/g, "│").replace(/\n/g, " ")}\``; -} - -/** - * Sanitise a pre-formatted table cell value for safe embedding in a raw - * markdown `| cell |` row. - * - * Unlike {@link escapeMarkdownCell}, this helper does **not** backslash-escape - * the backslash character. It is designed for values that may already contain - * inline markdown formatting (e.g. code spans) where `\\` would corrupt the - * rendered output. Instead of `\|`, pipe characters are replaced with - * `│` (U+2502, BOX DRAWINGS LIGHT VERTICAL) — visually identical but not a - * CommonMark table delimiter. - * - * @param value - Cell value that may include inline markdown (backtick spans, - * bold, etc.) - * @returns Value with `|` replaced by `│` and newlines collapsed to a space - */ -function sanitizeKvCell(value: string): string { - return value.replace(/\n/g, " ").replace(/\|/g, "│"); -} - /** * Build a key-value markdown table section with an optional heading. * * Each entry is rendered as `| **Label** | value |`. - * Uses the blank-header-row pattern required by marked-terminal. - * - * Values are sanitised via {@link sanitizeKvCell} — callers may pass raw text - * or pre-formatted inline markdown (e.g. `` `code` ``, `**bold**`). Raw text - * values with user-supplied content should be wrapped in {@link safeCodeSpan} - * or {@link escapeMarkdownCell} before passing if precise escaping is needed. - * - * @param rows - `[label, value]` tuples - * @param heading - Optional `### Heading` text (omit the `###` prefix) - * @returns Raw markdown string (not rendered) */ export function mdKvTable( rows: ReadonlyArray, @@ -273,38 +153,253 @@ export function mdKvTable( lines.push("| | |"); lines.push("|---|---|"); for (const [label, value] of rows) { - lines.push(`| **${label}** | ${sanitizeKvCell(value)} |`); + lines.push( + `| **${label}** | ${value.replace(/\n/g, " ").replace(/\|/g, "\u2502")} |` + ); } return lines.join("\n"); } /** - * Render a muted horizontal rule for streaming header separators. - * - * Centralises the divider character so all headers share a single style. - * - * @param width - Number of characters (defaults to 80) - * @returns Muted string of box-drawing dashes + * Render a muted horizontal rule. */ export function divider(width = 80): string { return muted("\u2500".repeat(width)); } +// ──────────────────────── Inline token rendering ───────────────────── + +/** Sentinel-inspired color palette */ +const COLORS = { + yellow: "#FDB81B", + blue: "#226DFC", + cyan: "#79B8FF", + muted: "#898294", +} as const; + +/** + * Syntax-highlight a code block. Falls back to uniform yellow if the + * language is unknown or highlighting fails. + */ +function highlightCode(code: string, language?: string): string { + try { + return cliHighlight(code, { language, ignoreIllegals: true }); + } catch { + return chalk.hex(COLORS.yellow)(code); + } +} + +/** + * Flatten a top-level token's inline content. Paragraphs and other block + * tokens that wrap inline tokens are unwrapped; bare inline tokens pass + * through as-is. + */ +function flattenInline(token: Token): Token[] { + if ( + "tokens" in token && + token.tokens && + token.type !== "strong" && + token.type !== "em" && + token.type !== "link" + ) { + return token.tokens; + } + return [token]; +} + +/** + * Render an array of inline tokens into an ANSI-styled string. + * + * Handles: strong, em, codespan, link, text, br, del, escape, html. + */ +function renderInline(tokens: Token[]): string { + return tokens + .map((token) => { + switch (token.type) { + case "strong": + return chalk.bold(renderInline((token as Tokens.Strong).tokens)); + case "em": + return chalk.italic(renderInline((token as Tokens.Em).tokens)); + case "codespan": + return chalk.hex(COLORS.yellow)((token as Tokens.Codespan).text); + case "link": { + const link = token as Tokens.Link; + const linkText = renderInline(link.tokens); + const href = link.href ? ` (${link.href})` : ""; + return chalk.hex(COLORS.blue)(`${linkText}${href}`); + } + case "del": + return chalk.dim.gray.strikethrough( + renderInline((token as Tokens.Del).tokens) + ); + case "br": + return "\n"; + case "escape": + return (token as Tokens.Escape).text; + case "text": + // Text tokens may themselves contain sub-tokens (e.g. from + // autolinked URLs or inline markup inside list items) + if ("tokens" in token && (token as Tokens.Text).tokens) { + return renderInline((token as Tokens.Text).tokens ?? []); + } + return (token as Tokens.Text).text; + case "html": + return ""; // Strip inline HTML + default: + return (token as { raw?: string }).raw ?? ""; + } + }) + .join(""); +} + +// ──────────────────────── Block token rendering ────────────────────── + +/** + * Render an array of block-level tokens into ANSI-styled terminal output. + * + * Handles: heading, paragraph, code, blockquote, list, table, hr, space. + */ +function renderBlocks(tokens: Token[]): string { + const parts: string[] = []; + + for (const token of tokens) { + switch (token.type) { + case "heading": { + const t = token as Tokens.Heading; + const text = renderInline(t.tokens); + if (t.depth <= 2) { + parts.push(chalk.hex(COLORS.cyan).bold(text)); + } else { + parts.push(chalk.hex(COLORS.cyan).bold(text)); + } + parts.push(""); + break; + } + case "paragraph": { + const t = token as Tokens.Paragraph; + parts.push(renderInline(t.tokens)); + parts.push(""); + break; + } + case "code": { + const t = token as Tokens.Code; + const highlighted = highlightCode(t.text, t.lang ?? undefined); + const lines = highlighted.split("\n").map((l) => ` ${l}`); + parts.push(lines.join("\n")); + parts.push(""); + break; + } + case "blockquote": { + const t = token as Tokens.Blockquote; + const inner = renderBlocks(t.tokens).trim(); + const quoted = inner + .split("\n") + .map((l) => chalk.hex(COLORS.muted).italic(` ${l}`)) + .join("\n"); + parts.push(quoted); + parts.push(""); + break; + } + case "list": { + const t = token as Tokens.List; + parts.push(renderList(t)); + parts.push(""); + break; + } + case "table": { + const t = token as Tokens.Table; + parts.push(renderTableToken(t)); + break; + } + case "hr": + parts.push(muted("\u2500".repeat(40))); + parts.push(""); + break; + case "space": + // Intentional blank line — skip + break; + default: { + // Unknown block type — emit raw text as fallback + const raw = (token as { raw?: string }).raw; + if (raw) { + parts.push(raw); + } + } + } + } + + return parts.join("\n"); +} + /** - * Render a full markdown document as styled terminal output, or return the - * raw CommonMark string when in plain mode. + * Render a list token (ordered or unordered) with proper indentation. + */ +function renderList(list: Tokens.List, depth = 0): string { + const indent = " ".repeat(depth); + const lines: string[] = []; + + for (let i = 0; i < list.items.length; i++) { + const item = list.items[i]; + if (!item) { + continue; + } + const start = Number(list.start ?? 1); + const bullet = list.ordered ? `${start + i}.` : "•"; + const body = item.tokens + .map((t) => { + if (t.type === "list") { + return renderList(t as Tokens.List, depth + 1); + } + if (t.type === "text" && "tokens" in t && (t as Tokens.Text).tokens) { + return renderInline((t as Tokens.Text).tokens ?? []); + } + if ("tokens" in t && (t as Tokens.Generic).tokens) { + return renderInline(((t as Tokens.Generic).tokens as Token[]) ?? []); + } + return (t as { raw?: string }).raw ?? ""; + }) + .join("\n"); + + lines.push(`${indent}${bullet} ${body}`); + } + + return lines.join("\n"); +} + +/** + * Render a markdown table token using the text-table renderer. * - * Supports the full CommonMark spec: - * - Headings, bold, italic, strikethrough - * - Fenced code blocks with syntax highlighting (via cli-highlight) - * - Inline code spans - * - Tables rendered with Unicode box-drawing (via cli-table3) - * - Ordered and unordered lists - * - Blockquotes - * - Links and images - * - Horizontal rules + * Converts marked's `Tokens.Table` into headers + rows + alignments and + * delegates to `renderTextTable()` for column fitting and box drawing. + */ +function renderTableToken(table: Tokens.Table): string { + const headers = table.header.map((cell) => renderInline(cell.tokens)); + const rows = table.rows.map((row) => + row.map((cell) => renderInline(cell.tokens)) + ); + + const alignments: Alignment[] = table.align.map((a) => { + if (a === "right") { + return "right"; + } + if (a === "center") { + return "center"; + } + return "left"; + }); + + return renderTextTable(headers, rows, { alignments }); +} + +// ──────────────────────── Public API ───────────────────────────────── + +/** + * Render a full markdown document as styled terminal output, or return + * the raw CommonMark string when in plain mode. * - * Pre-rendered ANSI escape codes in the input are preserved. + * Uses `marked.lexer()` to tokenize and a custom block/inline renderer + * for ANSI output. Tables are rendered with Unicode box-drawing borders + * via the text-table module. * * @param md - Markdown source text * @returns Styled terminal string (TTY) or raw CommonMark (non-TTY / plain mode) @@ -313,18 +408,14 @@ export function renderMarkdown(md: string): string { if (isPlainOutput()) { return md.trimEnd(); } - return (marked.parse(md) as string).trimEnd(); + const tokens = marked.lexer(md); + return renderBlocks(tokens).trimEnd(); } /** * Render inline markdown (bold, code spans, emphasis, links) as styled * terminal output, or return the raw markdown string when in plain mode. * - * Unlike `renderMarkdown()`, this uses `marked.parseInline()` which handles - * only inline-level constructs — no paragraph wrapping, no block elements. - * Suitable for styling individual table cell values in streaming formatters - * that write rows incrementally rather than as a complete table. - * * @param md - Inline markdown text * @returns Styled string (TTY) or raw markdown text (non-TTY / plain mode) */ @@ -332,5 +423,6 @@ export function renderInlineMarkdown(md: string): string { if (isPlainOutput()) { return md; } - return marked.parseInline(md) as string; + const tokens = marked.lexer(md); + return renderInline(tokens.flatMap(flattenInline)); } diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index 8083a8f1..d58d0b7d 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -1,14 +1,17 @@ /** * Generic column-based table renderer. * - * Generates markdown tables and renders them through `renderMarkdown()` so - * all list commands get consistent Unicode-bordered tables via cli-table3. - * Pre-rendered ANSI escape codes in cell values are preserved — cli-table3 - * uses string-width which correctly treats them as zero-width. + * Provides `writeTable()` for rendering structured data as Unicode-bordered + * tables directly via the text-table renderer, and `buildMarkdownTable()` + * for producing raw CommonMark table syntax (used in plain/non-TTY mode). + * + * ANSI escape codes in cell values are preserved — `string-width` correctly + * treats them as zero-width for column sizing. */ import type { Writer } from "../../types/index.js"; -import { escapeMarkdownCell, renderMarkdown } from "./markdown.js"; +import { escapeMarkdownCell, isPlainOutput } from "./markdown.js"; +import { type Alignment, renderTextTable } from "./text-table.js"; /** * Describes a single column in a table. @@ -25,16 +28,12 @@ export type Column = { }; /** - * Build a markdown table string from items and column definitions. + * Build a raw CommonMark table string from items and column definitions. * * Cell values are escaped via {@link escapeMarkdownCell} so pipe and * backslash characters in API-supplied strings don't break the table. - * Pre-rendered ANSI codes survive the pipeline — cli-table3 uses - * `string-width` for column width calculation. * - * @param items - Row data - * @param columns - Column definitions - * @returns Markdown table string + * Used for plain/non-TTY output mode. */ export function buildMarkdownTable( items: T[], @@ -52,10 +51,10 @@ export function buildMarkdownTable( } /** - * Render items as a formatted table with Unicode borders. + * Render items as a formatted table. * - * Column widths are auto-sized by cli-table3. Columns are defined via the - * `columns` array; ANSI-colored cell values are preserved. + * In TTY mode: renders directly via text-table with Unicode box borders. + * In plain mode: emits raw CommonMark table syntax. * * @param stdout - Output writer * @param items - Row data @@ -66,5 +65,14 @@ export function writeTable( items: T[], columns: Column[] ): void { - stdout.write(`${renderMarkdown(buildMarkdownTable(items, columns))}\n`); + if (isPlainOutput()) { + stdout.write(`${buildMarkdownTable(items, columns)}\n`); + return; + } + + const headers = columns.map((c) => c.header); + const rows = items.map((item) => columns.map((c) => c.value(item))); + const alignments: Alignment[] = columns.map((c) => c.align ?? "left"); + + stdout.write(renderTextTable(headers, rows, { alignments })); } diff --git a/src/lib/formatters/text-table.ts b/src/lib/formatters/text-table.ts new file mode 100644 index 00000000..b47c1dd7 --- /dev/null +++ b/src/lib/formatters/text-table.ts @@ -0,0 +1,472 @@ +/** + * ANSI-aware text table renderer with Unicode box-drawing borders. + * + * Column fitting algorithms ported from OpenTUI's TextTable. + * Measures string widths with `string-width` (handles ANSI codes, emoji, + * CJK characters) and wraps with `wrap-ansi` for correct ANSI sequence + * continuation across line breaks. + * + * @see https://github.com/anomalyco/opentui/blob/main/packages/core/src/renderables/TextTable.ts + */ + +import stringWidth from "string-width"; +import wrapAnsi from "wrap-ansi"; +import { + type BorderCharacters, + BorderChars, + type BorderStyle, +} from "./border.js"; + +/** Column alignment. */ +export type Alignment = "left" | "right" | "center"; + +/** Options for rendering a text table. */ +export type TextTableOptions = { + /** Border style. @default "rounded" */ + borderStyle?: BorderStyle; + /** Column fitting strategy when table exceeds maxWidth. @default "balanced" */ + columnFitter?: "proportional" | "balanced"; + /** Horizontal cell padding (each side). @default 1 */ + cellPadding?: number; + /** Maximum table width in columns. @default process.stdout.columns or 80 */ + maxWidth?: number; + /** Per-column alignment (indexed by column). Defaults to "left". */ + alignments?: Array; + /** Whether to include a separator row after the header. @default true */ + headerSeparator?: boolean; +}; + +/** + * Render a text table with Unicode box-drawing borders. + * + * Cell values may contain ANSI escape codes — widths are computed correctly + * via `string-width` and word wrapping preserves ANSI sequences via `wrap-ansi`. + * + * @param headers - Column header strings + * @param rows - 2D array of cell values (outer = rows, inner = columns) + * @param options - Rendering options + * @returns Rendered table string with box-drawing borders and newline at end + */ +export function renderTextTable( + headers: string[], + rows: string[][], + options: TextTableOptions = {} +): string { + const { + borderStyle = "rounded", + columnFitter = "balanced", + cellPadding = 1, + maxWidth = process.stdout.columns || 80, + alignments = [], + headerSeparator = true, + } = options; + + const border = BorderChars[borderStyle]; + const colCount = headers.length; + if (colCount === 0) { + return ""; + } + + // Measure intrinsic column widths from all content + const intrinsicWidths = measureIntrinsicWidths( + headers, + rows, + colCount, + cellPadding + ); + + // Fit columns to available width + // Border overhead: outerLeft(1) + outerRight(1) + innerSeparators(colCount-1) + const borderOverhead = 2 + (colCount - 1); + const maxContentWidth = Math.max(colCount, maxWidth - borderOverhead); + const columnWidths = fitColumns( + intrinsicWidths, + maxContentWidth, + cellPadding, + columnFitter + ); + + // Build all rows (header + optional separator + data rows) + const allRows: string[][][] = []; + + // Header row + allRows.push(wrapRow(headers, columnWidths, cellPadding)); + + // Data rows + for (const row of rows) { + allRows.push(wrapRow(row, columnWidths, cellPadding)); + } + + // Render the grid + return renderGrid({ + allRows, + columnWidths, + alignments, + border, + cellPadding, + headerSeparator, + }); +} + +/** + * Measure the intrinsic (unconstrained) width of each column. + * Returns the maximum visual width across all rows for each column, + * plus horizontal padding. + */ +function measureIntrinsicWidths( + headers: string[], + rows: string[][], + colCount: number, + cellPadding: number +): number[] { + const pad = cellPadding * 2; + const widths: number[] = []; + + for (let c = 0; c < colCount; c++) { + // Start with header width + let maxW = stringWidth(headers[c] ?? "") + pad; + + // Check all data rows + for (const row of rows) { + const cellWidth = stringWidth(row[c] ?? "") + pad; + if (cellWidth > maxW) { + maxW = cellWidth; + } + } + + // Minimum: padding + 1 char + widths.push(Math.max(maxW, pad + 1)); + } + + return widths; +} + +/** + * Fit column widths to the available content width. + * + * If columns fit naturally, returns intrinsic widths. + * If columns exceed the max, shrinks using the selected fitter. + */ +function fitColumns( + intrinsicWidths: number[], + maxContentWidth: number, + cellPadding: number, + fitter: "proportional" | "balanced" +): number[] { + const totalIntrinsic = intrinsicWidths.reduce((s, w) => s + w, 0); + + if (totalIntrinsic <= maxContentWidth) { + return intrinsicWidths; + } + + if (fitter === "balanced") { + return fitBalanced(intrinsicWidths, maxContentWidth, cellPadding); + } + return fitProportional(intrinsicWidths, maxContentWidth, cellPadding); +} + +/** + * Proportional column fitting: shrinks each column proportional to its + * excess over the minimum width. + * + * Ported from OpenTUI's fitColumnWidthsProportional. + */ +function fitProportional( + widths: number[], + target: number, + cellPadding: number +): number[] { + const minWidth = 1 + cellPadding * 2; + const baseWidths = widths.map((w) => Math.max(minWidth, Math.floor(w))); + const totalBase = baseWidths.reduce((s, w) => s + w, 0); + + if (totalBase <= target) { + return baseWidths; + } + + const floorWidths = baseWidths.map((w) => Math.min(w, minWidth + 1)); + const floorTotal = floorWidths.reduce((s, w) => s + w, 0); + const clampedTarget = Math.max(floorTotal, target); + + if (totalBase <= clampedTarget) { + return baseWidths; + } + + const shrinkable = baseWidths.map((w, i) => w - (floorWidths[i] ?? 0)); + const totalShrinkable = shrinkable.reduce((s, v) => s + v, 0); + if (totalShrinkable <= 0) { + return [...floorWidths]; + } + + return allocateShrink({ + baseWidths, + floorWidths, + shrinkable, + targetShrink: totalBase - clampedTarget, + mode: "linear", + }); +} + +/** + * Balanced column fitting: uses sqrt-weighted shrinking so wide columns + * don't dominate the shrink allocation. + * + * Ported from OpenTUI's fitColumnWidthsBalanced. + */ +function fitBalanced( + widths: number[], + target: number, + cellPadding: number +): number[] { + const minWidth = 1 + cellPadding * 2; + const baseWidths = widths.map((w) => Math.max(minWidth, Math.floor(w))); + const totalBase = baseWidths.reduce((s, w) => s + w, 0); + + if (totalBase <= target) { + return baseWidths; + } + + const evenShare = Math.max(minWidth, Math.floor(target / baseWidths.length)); + const floorWidths = baseWidths.map((w) => Math.min(w, evenShare)); + const floorTotal = floorWidths.reduce((s, w) => s + w, 0); + const clampedTarget = Math.max(floorTotal, target); + + if (totalBase <= clampedTarget) { + return baseWidths; + } + + const shrinkable = baseWidths.map((w, i) => w - (floorWidths[i] ?? 0)); + const totalShrinkable = shrinkable.reduce((s, v) => s + v, 0); + if (totalShrinkable <= 0) { + return [...floorWidths]; + } + + return allocateShrink({ + baseWidths, + floorWidths, + shrinkable, + targetShrink: totalBase - clampedTarget, + mode: "sqrt", + }); +} + +/** Parameters for the shrink allocation algorithm. */ +type ShrinkParams = { + baseWidths: number[]; + floorWidths: number[]; + shrinkable: number[]; + targetShrink: number; + mode: "linear" | "sqrt"; +}; + +/** + * Distribute shrink across columns using weighted allocation with + * fractional remainder distribution for pixel-perfect results. + * + * Ported from OpenTUI's allocateShrinkByWeight. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ported algorithm +function allocateShrink(params: ShrinkParams): number[] { + const { baseWidths, floorWidths, shrinkable, targetShrink, mode } = params; + const computeWeight = (v: number) => { + if (v <= 0) { + return 0; + } + return mode === "sqrt" ? Math.sqrt(v) : v; + }; + const weights = shrinkable.map(computeWeight); + const totalWeight = weights.reduce((s, v) => s + v, 0); + + if (totalWeight <= 0) { + return [...floorWidths]; + } + + const shrink = new Array(baseWidths.length).fill(0); + const fractions = new Array(baseWidths.length).fill(0); + let usedShrink = 0; + + for (let i = 0; i < baseWidths.length; i++) { + const s = shrinkable[i] ?? 0; + const wt = weights[i] ?? 0; + if (s <= 0 || wt <= 0) { + continue; + } + const exact = (wt / totalWeight) * targetShrink; + const whole = Math.min(s, Math.floor(exact)); + shrink[i] = whole; + fractions[i] = exact - whole; + usedShrink += whole; + } + + // Distribute fractional remainders to columns with largest fractions + let remaining = targetShrink - usedShrink; + while (remaining > 0) { + let bestIdx = -1; + let bestFrac = -1; + for (let i = 0; i < baseWidths.length; i++) { + const s = shrinkable[i] ?? 0; + const sh = shrink[i] ?? 0; + if (s - sh <= 0) { + continue; + } + const f = fractions[i] ?? 0; + if ( + f > bestFrac || + (f === bestFrac && bestIdx >= 0 && s > (shrinkable[bestIdx] ?? 0)) + ) { + bestFrac = f; + bestIdx = i; + } + } + if (bestIdx === -1) { + break; + } + shrink[bestIdx] = (shrink[bestIdx] ?? 0) + 1; + fractions[bestIdx] = 0; + remaining -= 1; + } + + return baseWidths.map((w, i) => + Math.max(floorWidths[i] ?? 0, w - (shrink[i] ?? 0)) + ); +} + +/** + * Wrap a row's cell values to their allocated column widths. + * Returns an array of lines per cell (for multi-line rows). + */ +function wrapRow( + cells: string[], + columnWidths: number[], + cellPadding: number +): string[][] { + const wrappedCells: string[][] = []; + for (let c = 0; c < columnWidths.length; c++) { + const contentWidth = (columnWidths[c] ?? 3) - cellPadding * 2; + const text = c < cells.length ? (cells[c] ?? "") : ""; + if (contentWidth <= 0) { + wrappedCells.push([""]); + continue; + } + const wrapped = wrapAnsi(text, contentWidth, { hard: true, trim: false }); + wrappedCells.push(wrapped.split("\n")); + } + return wrappedCells; +} + +/** + * Pad a cell value to its column width respecting alignment. + * Uses string-width for ANSI-aware padding calculation. + */ +function padCell( + text: string, + width: number, + align: Alignment, + padding: number +): string { + const contentWidth = width - padding * 2; + const textWidth = stringWidth(text); + const pad = Math.max(0, contentWidth - textWidth); + const leftPad = " ".repeat(padding); + const rightPad = " ".repeat(padding); + + switch (align) { + case "right": + return `${leftPad}${" ".repeat(pad)}${text}${rightPad}`; + case "center": { + const left = Math.floor(pad / 2); + return `${leftPad}${" ".repeat(left)}${text}${" ".repeat(pad - left)}${rightPad}`; + } + default: + return `${leftPad}${text}${" ".repeat(pad)}${rightPad}`; + } +} + +/** Parameters for grid rendering. */ +type GridParams = { + allRows: string[][][]; + columnWidths: number[]; + alignments: Array; + border: BorderCharacters; + cellPadding: number; + headerSeparator: boolean; +}; + +/** + * Render the complete table grid with borders. + */ +function renderGrid(params: GridParams): string { + const { + allRows, + columnWidths, + alignments, + border, + cellPadding, + headerSeparator, + } = params; + const lines: string[] = []; + + const hz = border.horizontal; + + // Top border + lines.push( + horizontalLine(columnWidths, { + left: border.topLeft, + junction: border.topT, + right: border.topRight, + horizontal: hz, + }) + ); + + for (let r = 0; r < allRows.length; r++) { + const wrappedCells = allRows[r] ?? []; + const rowHeight = Math.max(1, ...wrappedCells.map((c) => c.length)); + + for (let line = 0; line < rowHeight; line++) { + const cellTexts: string[] = []; + for (let c = 0; c < columnWidths.length; c++) { + const cellLines = wrappedCells[c] ?? [""]; + const text = cellLines[line] ?? ""; + const align = alignments[c] ?? "left"; + const colW = columnWidths[c] ?? 3; + cellTexts.push(padCell(text, colW, align, cellPadding)); + } + lines.push( + `${border.vertical}${cellTexts.join(border.vertical)}${border.vertical}` + ); + } + + // Header separator + if (r === 0 && headerSeparator && allRows.length > 1) { + lines.push( + horizontalLine(columnWidths, { + left: border.leftT, + junction: border.cross, + right: border.rightT, + horizontal: hz, + }) + ); + } + } + + // Bottom border + lines.push( + horizontalLine(columnWidths, { + left: border.bottomLeft, + junction: border.bottomT, + right: border.bottomRight, + horizontal: hz, + }) + ); + + return `${lines.join("\n")}\n`; +} + +/** Build a horizontal border line from column widths and junction characters. */ +function horizontalLine( + columnWidths: number[], + chars: { left: string; junction: string; right: string; horizontal: string } +): string { + const segments = columnWidths.map((w) => chars.horizontal.repeat(w)); + return `${chars.left}${segments.join(chars.junction)}${chars.right}`; +} From ce59be96e12ef693ec21cf018141ca876ffde427 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 13:02:13 +0000 Subject: [PATCH 27/52] fix(formatters): restore in-cell markdown rendering and add terminal hyperlinks - writeTable() now passes cell values through renderInlineMarkdown() in TTY mode, restoring inline formatting (bold, code spans, links) that was lost when bypassing the markdown round-trip - Add terminalLink() helper using OSC 8 escape sequences for clickable text in supporting terminals (iTerm2, VS Code, Windows Terminal, etc.) - Issue list SHORT ID column is now a terminal hyperlink to the issue permalink URL; string-width treats OSC 8 as zero-width so column sizing is unaffected --- src/lib/formatters/colors.ts | 21 +++++++++++++++++++++ src/lib/formatters/human.ts | 10 ++++++++-- src/lib/formatters/table.ts | 10 ++++++++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/lib/formatters/colors.ts b/src/lib/formatters/colors.ts index e7f841d4..d542ee48 100644 --- a/src/lib/formatters/colors.ts +++ b/src/lib/formatters/colors.ts @@ -36,6 +36,27 @@ export const underline = (text: string): string => chalk.underline(text); export const boldUnderline = (text: string): string => chalk.bold.underline(text); +/** + * Wrap text in an OSC 8 terminal hyperlink. + * + * On terminals that support OSC 8 (iTerm2, Windows Terminal, VS Code, + * most modern emulators), the text becomes clickable. On terminals that + * don't, the escape sequences are silently ignored and the text renders + * normally. + * + * `string-width` treats OSC 8 sequences as zero-width, so column sizing + * in tables is not affected. + * + * @param text - Display text + * @param url - Target URL + * @returns Text wrapped in OSC 8 hyperlink escape sequences + */ +export function terminalLink(text: string, url: string): string { + // OSC 8 ; params ; URI ST text OSC 8 ; ; ST + // Using BEL () as string terminator for broad compatibility + return `]8;;${url}${text}]8;;`; +} + // Semantic Helpers /** Format success messages (green) */ diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 64d49d58..5fbb53bf 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -33,6 +33,7 @@ import { levelColor, muted, statusColor, + terminalLink, yellow, } from "./colors.js"; import { @@ -424,8 +425,13 @@ export function writeIssueTable( columns.push( { header: "SHORT ID", - value: ({ issue, formatOptions }) => - formatShortId(issue.shortId, formatOptions), + value: ({ issue, formatOptions }) => { + const formatted = formatShortId(issue.shortId, formatOptions); + if (issue.permalink) { + return terminalLink(formatted, issue.permalink); + } + return formatted; + }, }, { header: "COUNT", diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index d58d0b7d..7adcd070 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -10,7 +10,11 @@ */ import type { Writer } from "../../types/index.js"; -import { escapeMarkdownCell, isPlainOutput } from "./markdown.js"; +import { + escapeMarkdownCell, + isPlainOutput, + renderInlineMarkdown, +} from "./markdown.js"; import { type Alignment, renderTextTable } from "./text-table.js"; /** @@ -71,7 +75,9 @@ export function writeTable( } const headers = columns.map((c) => c.header); - const rows = items.map((item) => columns.map((c) => c.value(item))); + const rows = items.map((item) => + columns.map((c) => renderInlineMarkdown(c.value(item))) + ); const alignments: Alignment[] = columns.map((c) => c.align ?? "left"); stdout.write(renderTextTable(headers, rows, { alignments })); From c6a5519bb4098eb6afa8a324817f95e3b5b39c19 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 13:09:04 +0000 Subject: [PATCH 28/52] fix(formatters): don't run cell values through markdown parser in writeTable Cell values from column value() functions are already pre-formatted with ANSI codes (chalk) and terminal hyperlinks (OSC 8). Running them through renderInlineMarkdown() caused marked.lexer() to parse URLs inside OSC 8 hyperlink sequences as markdown autolinks, rendering them as visible 'text (url)' and blowing up column widths. --- src/lib/formatters/table.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index 7adcd070..d58d0b7d 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -10,11 +10,7 @@ */ import type { Writer } from "../../types/index.js"; -import { - escapeMarkdownCell, - isPlainOutput, - renderInlineMarkdown, -} from "./markdown.js"; +import { escapeMarkdownCell, isPlainOutput } from "./markdown.js"; import { type Alignment, renderTextTable } from "./text-table.js"; /** @@ -75,9 +71,7 @@ export function writeTable( } const headers = columns.map((c) => c.header); - const rows = items.map((item) => - columns.map((c) => renderInlineMarkdown(c.value(item))) - ); + const rows = items.map((item) => columns.map((c) => c.value(item))); const alignments: Alignment[] = columns.map((c) => c.align ?? "left"); stdout.write(renderTextTable(headers, rows, { alignments })); From 0e59671c3602a7222ffed4c032c2173b21b2db59 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 13:20:53 +0000 Subject: [PATCH 29/52] refactor(formatters): use markdown in table cells, render links as OSC 8 hyperlinks Cell values in writeTable() are now markdown strings processed through renderInlineMarkdown() before column sizing. This means **bold**, `code`, and [text](url) in cell values render as styled/clickable text. Pre-existing ANSI codes (chalk colors for levels) pass through untouched. - Link rendering in the custom markdown renderer now emits OSC 8 terminal hyperlinks (blue + clickable) instead of showing URLs as visible 'text (url)' text - Issue list SHORT ID column uses markdown link syntax [**CLI-1D**](permalink) instead of terminalLink() + formatShortId() - terminalLink() remains available in colors.ts for direct use --- src/lib/formatters/human.ts | 8 +++----- src/lib/formatters/markdown.ts | 6 +++--- src/lib/formatters/table.ts | 17 ++++++++++++++--- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 5fbb53bf..a499b9bf 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -33,7 +33,6 @@ import { levelColor, muted, statusColor, - terminalLink, yellow, } from "./colors.js"; import { @@ -425,12 +424,11 @@ export function writeIssueTable( columns.push( { header: "SHORT ID", - value: ({ issue, formatOptions }) => { - const formatted = formatShortId(issue.shortId, formatOptions); + value: ({ issue }) => { if (issue.permalink) { - return terminalLink(formatted, issue.permalink); + return `[**${issue.shortId}**](${issue.permalink})`; } - return formatted; + return `**${issue.shortId}**`; }, }, { diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 93dbb9e3..711c4d18 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -26,7 +26,7 @@ import chalk from "chalk"; import { highlight as cliHighlight } from "cli-highlight"; import { marked, type Token, type Tokens } from "marked"; -import { muted } from "./colors.js"; +import { muted, terminalLink } from "./colors.js"; import { type Alignment, renderTextTable } from "./text-table.js"; // ──────────────────────────── Environment ───────────────────────────── @@ -225,8 +225,8 @@ function renderInline(tokens: Token[]): string { case "link": { const link = token as Tokens.Link; const linkText = renderInline(link.tokens); - const href = link.href ? ` (${link.href})` : ""; - return chalk.hex(COLORS.blue)(`${linkText}${href}`); + const styled = chalk.hex(COLORS.blue)(linkText); + return link.href ? terminalLink(styled, link.href) : styled; } case "del": return chalk.dim.gray.strikethrough( diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index d58d0b7d..2bab5e27 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -10,7 +10,11 @@ */ import type { Writer } from "../../types/index.js"; -import { escapeMarkdownCell, isPlainOutput } from "./markdown.js"; +import { + escapeMarkdownCell, + isPlainOutput, + renderInlineMarkdown, +} from "./markdown.js"; import { type Alignment, renderTextTable } from "./text-table.js"; /** @@ -53,7 +57,12 @@ export function buildMarkdownTable( /** * Render items as a formatted table. * - * In TTY mode: renders directly via text-table with Unicode box borders. + * Cell values are markdown strings — in TTY mode they are rendered through + * \ before column sizing, so \, + * \code\, and \ in cell values render as styled/clickable text. + * Pre-existing ANSI codes (e.g. chalk colors) pass through the markdown + * parser untouched. + * * In plain mode: emits raw CommonMark table syntax. * * @param stdout - Output writer @@ -71,7 +80,9 @@ export function writeTable( } const headers = columns.map((c) => c.header); - const rows = items.map((item) => columns.map((c) => c.value(item))); + const rows = items.map((item) => + columns.map((c) => renderInlineMarkdown(c.value(item))) + ); const alignments: Alignment[] = columns.map((c) => c.align ?? "left"); stdout.write(renderTextTable(headers, rows, { alignments })); From 744e9449f72ac3c124a349a3d4b0dda4d682efb3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 13:42:59 +0000 Subject: [PATCH 30/52] refactor(formatters): convert formatShortId to markdown output, fix tests formatShortId() now returns markdown bold (**text**) instead of ANSI boldUnderline() for alias highlighting. The SHORT ID column in the issue list uses markdown link syntax [**shortId**](permalink) which the inline renderer converts to blue OSC 8 hyperlinks. Updated property and unit tests to strip markdown bold markers alongside ANSI codes when verifying content invariants. --- src/lib/formatters/human.ts | 18 +++--- test/lib/formatters/human.property.test.ts | 13 +++-- test/lib/formatters/human.test.ts | 68 ++++++++++------------ 3 files changed, 48 insertions(+), 51 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index a499b9bf..2c2ac3b8 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -26,7 +26,6 @@ import type { } from "../../types/index.js"; import { withSerializeSpan } from "../telemetry.js"; import { - boldUnderline, type FixabilityTier, fixabilityColor, green, @@ -310,20 +309,20 @@ function formatShortIdWithAlias( if (part?.startsWith(aliasUpper)) { const result = projectParts.map((p, idx) => { if (idx === i) { - return boldUnderline(p.slice(0, aliasLen)) + p.slice(aliasLen); + return `**${p.slice(0, aliasLen)}**${p.slice(aliasLen)}`; } return p; }); - return `${result.join("-")}-${boldUnderline(issueSuffix)}`; + return `${result.join("-")}-**${issueSuffix}**`; } } } const projectPortion = projectParts.join("-"); if (projectPortion.startsWith(aliasUpper)) { - const highlighted = boldUnderline(projectPortion.slice(0, aliasLen)); + const highlighted = `**${projectPortion.slice(0, aliasLen)}**`; const rest = projectPortion.slice(aliasLen); - return `${highlighted}${rest}-${boldUnderline(issueSuffix)}`; + return `${highlighted}${rest}-**${issueSuffix}**`; } return null; @@ -360,7 +359,7 @@ export function formatShortId( const prefix = `${projectSlug.toUpperCase()}-`; if (upperShortId.startsWith(prefix)) { const suffix = shortId.slice(prefix.length); - return `${prefix}${boldUnderline(suffix.toUpperCase())}`; + return `${prefix}**${suffix.toUpperCase()}**`; } } @@ -424,11 +423,12 @@ export function writeIssueTable( columns.push( { header: "SHORT ID", - value: ({ issue }) => { + value: ({ issue, formatOptions }) => { + const formatted = formatShortId(issue.shortId, formatOptions); if (issue.permalink) { - return `[**${issue.shortId}**](${issue.permalink})`; + return `[${formatted}](${issue.permalink})`; } - return `**${issue.shortId}**`; + return formatted; }, }, { diff --git a/test/lib/formatters/human.property.test.ts b/test/lib/formatters/human.property.test.ts index 11423805..c4261c48 100644 --- a/test/lib/formatters/human.property.test.ts +++ b/test/lib/formatters/human.property.test.ts @@ -30,6 +30,11 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, ""); } +/** Strip both ANSI escape codes and markdown bold markers. */ +function stripFormatting(s: string): string { + return stripAnsi(s).replace(/\*\*/g, ""); +} + // Arbitraries /** Project slug (lowercase, alphanumeric with hyphens) */ @@ -60,7 +65,7 @@ describe("formatShortId properties", () => { await fcAssert( property(shortIdArb, (shortId) => { const result = formatShortId(shortId); - expect(stripAnsi(result)).toBe(shortId.toUpperCase()); + expect(stripFormatting(result)).toBe(shortId.toUpperCase()); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -70,7 +75,7 @@ describe("formatShortId properties", () => { await fcAssert( property(shortIdArb, (shortId) => { const result = formatShortId(shortId); - expect(stripAnsi(result).length).toBe(shortId.length); + expect(stripFormatting(result).length).toBe(shortId.length); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -81,7 +86,7 @@ describe("formatShortId properties", () => { property(tuple(projectSlugArb, suffixArb), ([project, suffix]) => { const shortId = `${project}-${suffix}`; const result = formatShortId(shortId, { projectSlug: project }); - expect(stripAnsi(result)).toBe(shortId.toUpperCase()); + expect(stripFormatting(result)).toBe(shortId.toUpperCase()); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -96,7 +101,7 @@ describe("formatShortId properties", () => { projectSlug: project, projectAlias: alias, }); - expect(stripAnsi(result)).toBe(shortId.toUpperCase()); + expect(stripFormatting(result)).toBe(shortId.toUpperCase()); }), { numRuns: DEFAULT_NUM_RUNS } ); diff --git a/test/lib/formatters/human.test.ts b/test/lib/formatters/human.test.ts index 1724ab11..7a491a61 100644 --- a/test/lib/formatters/human.test.ts +++ b/test/lib/formatters/human.test.ts @@ -21,67 +21,58 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, ""); } +/** Strip both ANSI escape codes and markdown bold markers. */ +function stripFormatting(s: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return s.replace(/\x1b\[[0-9;]*m/g, "").replace(/\*\*/g, ""); +} + describe("formatShortId edge cases", () => { test("handles empty options object", () => { - expect(stripAnsi(formatShortId("CRAFT-G", {}))).toBe("CRAFT-G"); + expect(stripFormatting(formatShortId("CRAFT-G", {}))).toBe("CRAFT-G"); }); test("handles undefined options", () => { - expect(stripAnsi(formatShortId("CRAFT-G", undefined))).toBe("CRAFT-G"); + expect(stripFormatting(formatShortId("CRAFT-G", undefined))).toBe( + "CRAFT-G" + ); }); test("handles mismatched project slug gracefully", () => { const result = formatShortId("CRAFT-G", { projectSlug: "other" }); - expect(stripAnsi(result)).toBe("CRAFT-G"); + expect(stripFormatting(result)).toBe("CRAFT-G"); }); test("handles legacy string parameter", () => { const result = formatShortId("CRAFT-G", "craft"); - expect(stripAnsi(result)).toBe("CRAFT-G"); + expect(stripFormatting(result)).toBe("CRAFT-G"); }); }); -describe("formatShortId ANSI formatting", () => { - // Note: These tests verify formatting is applied when colors are enabled. - // In CI/test environments without TTY, chalk may disable colors. - // Run with FORCE_COLOR=1 to test color output. - - // Helper to check for ANSI escape codes - function hasAnsiCodes(str: string): boolean { - // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars - return /\x1b\[[0-9;]*m/.test(str); - } - - // Check if colors are enabled (chalk respects FORCE_COLOR env) - const colorsEnabled = process.env.FORCE_COLOR === "1"; - +describe("formatShortId formatting", () => { test("single project mode applies formatting to suffix", () => { const result = formatShortId("CRAFT-G", { projectSlug: "craft" }); - // Content is always correct - expect(stripAnsi(result)).toBe("CRAFT-G"); - // ANSI codes only present when colors enabled - if (colorsEnabled) { - expect(hasAnsiCodes(result)).toBe(true); - expect(result.length).toBeGreaterThan(stripAnsi(result).length); - } + expect(stripFormatting(result)).toBe("CRAFT-G"); + // Suffix should be wrapped in markdown bold + expect(result).toContain("**G**"); }); test("multi-project mode applies formatting to suffix", () => { const result = formatShortId("SPOTLIGHT-ELECTRON-4Y", { projectSlug: "spotlight-electron", projectAlias: "e", + isMultiProject: true, }); - expect(stripAnsi(result)).toBe("SPOTLIGHT-ELECTRON-4Y"); - if (colorsEnabled) { - expect(hasAnsiCodes(result)).toBe(true); - expect(result.length).toBeGreaterThan(stripAnsi(result).length); - } + expect(stripFormatting(result)).toBe("SPOTLIGHT-ELECTRON-4Y"); + // Alias char and suffix should be bold + expect(result).toContain("**E**"); + expect(result).toContain("**4Y**"); }); test("no formatting when no options provided", () => { const result = formatShortId("CRAFT-G"); - expect(hasAnsiCodes(result)).toBe(false); expect(result).toBe("CRAFT-G"); + expect(result).not.toContain("**"); }); }); @@ -97,7 +88,7 @@ describe("formatShortId multi-project alias highlighting", () => { isMultiProject: true, }); // Content is always correct - the text should be unchanged - expect(stripAnsi(result)).toBe("API-APP-5"); + expect(stripFormatting(result)).toBe("API-APP-5"); }); test("highlights alias with embedded dash correctly", () => { @@ -107,7 +98,7 @@ describe("formatShortId multi-project alias highlighting", () => { projectAlias: "x-a", isMultiProject: true, }); - expect(stripAnsi(result)).toBe("X-AB-5"); + expect(stripFormatting(result)).toBe("X-AB-5"); }); test("highlights single char alias at start of multi-part short ID", () => { @@ -116,7 +107,7 @@ describe("formatShortId multi-project alias highlighting", () => { projectAlias: "w", isMultiProject: true, }); - expect(stripAnsi(result)).toBe("CLI-WEBSITE-4"); + expect(stripFormatting(result)).toBe("CLI-WEBSITE-4"); }); test("highlights single char alias in simple short ID", () => { @@ -125,7 +116,7 @@ describe("formatShortId multi-project alias highlighting", () => { projectAlias: "c", isMultiProject: true, }); - expect(stripAnsi(result)).toBe("CLI-25"); + expect(stripFormatting(result)).toBe("CLI-25"); }); test("handles org-prefixed alias format", () => { @@ -134,7 +125,7 @@ describe("formatShortId multi-project alias highlighting", () => { projectAlias: "o1/d", isMultiProject: true, }); - expect(stripAnsi(result)).toBe("DASHBOARD-A3"); + expect(stripFormatting(result)).toBe("DASHBOARD-A3"); }); test("falls back gracefully when alias doesn't match", () => { @@ -143,7 +134,7 @@ describe("formatShortId multi-project alias highlighting", () => { projectAlias: "xyz", isMultiProject: true, }); - expect(stripAnsi(result)).toBe("CLI-25"); + expect(stripFormatting(result)).toBe("CLI-25"); }); }); @@ -185,7 +176,8 @@ describe("writeIssueTable", () => { writeIssueTable(writer, rows, false); const text = stripAnsi(output()); expect(text).not.toContain("ALIAS"); - expect(text).toContain("DASHBOARD-A3"); + expect(text).toContain("DASHBOARD-"); + expect(text).toContain("A3"); expect(text).toContain("Test issue"); }); From e0184ce5f7832f1b405b84cc6a583c718babaf91 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 13:53:17 +0000 Subject: [PATCH 31/52] fix(e2e): strip markdown bold markers when asserting on issue short IDs The SHORT ID column now emits markdown bold syntax (**text**) in plain output mode (non-TTY). Update multi-region E2E assertions to strip ** before comparing, matching the pattern used in other e2e fixes. --- test/e2e/multiregion.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/e2e/multiregion.test.ts b/test/e2e/multiregion.test.ts index fc272326..f94a76e6 100644 --- a/test/e2e/multiregion.test.ts +++ b/test/e2e/multiregion.test.ts @@ -239,8 +239,10 @@ describe("multi-region", () => { ]); expect(result.exitCode).toBe(0); - // Should contain the US issue - expect(result.stdout).toContain("ACME-FRONTEND-1A"); + // Should contain the US issue (strip markdown bold markers from short ID) + expect(result.stdout.replace(/\*\*/g, "")).toContain( + "ACME-FRONTEND-1A" + ); }, { timeout: TEST_TIMEOUT } ); @@ -260,8 +262,8 @@ describe("multi-region", () => { ]); expect(result.exitCode).toBe(0); - // Should contain the EU issue - expect(result.stdout).toContain("EURO-PORTAL-1A"); + // Should contain the EU issue (strip markdown bold markers from short ID) + expect(result.stdout.replace(/\*\*/g, "")).toContain("EURO-PORTAL-1A"); }, { timeout: TEST_TIMEOUT } ); From f0a7f8a07945e2eb8a0a326499de115b2499db13 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 15:27:17 +0000 Subject: [PATCH 32/52] feat(formatters): add semantic HTML color tags for log levels and issue status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct chalk/ANSI color calls in markdown-emitting contexts with semantic text color tags handled by the custom renderer. - Add COLOR_TAGS map and colorTag() helper to markdown.ts - renderInline() now handles html tokens: text → chalk.hex(red)(text) - renderHtmlToken() extracted as module-level helper to stay within complexity limit - log.ts: SEVERITY_COLORS (chalk fns) → SEVERITY_TAGS (tag name strings) - human.ts: levelColor/fixabilityColor/statusColor/green/yellow/muted calls in markdown contexts → colorTag() with LEVEL_TAGS / FIXABILITY_TAGS maps - In plain (non-TTY) output mode, color tags are stripped leaving bare text - tests: strip markers alongside ANSI in content assertions --- src/lib/formatters/human.ts | 68 +++++++++++++---------- src/lib/formatters/log.ts | 38 ++++++------- src/lib/formatters/markdown.ts | 72 ++++++++++++++++++++++++- test/lib/formatters/human.utils.test.ts | 4 +- 4 files changed, 132 insertions(+), 50 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 2c2ac3b8..7cab21cc 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -25,16 +25,9 @@ import type { Writer, } from "../../types/index.js"; import { withSerializeSpan } from "../telemetry.js"; +import { type FixabilityTier, muted } from "./colors.js"; import { - type FixabilityTier, - fixabilityColor, - green, - levelColor, - muted, - statusColor, - yellow, -} from "./colors.js"; -import { + colorTag, escapeMarkdownCell, escapeMarkdownInline, renderMarkdown, @@ -42,18 +35,37 @@ import { } from "./markdown.js"; import { type Column, writeTable } from "./table.js"; +// Color tag maps + +/** Markdown color tags for issue level values */ +const LEVEL_TAGS: Record[0]> = { + fatal: "red", + error: "red", + warning: "yellow", + info: "cyan", + debug: "muted", +}; + +/** Markdown color tags for Seer fixability tiers */ +const FIXABILITY_TAGS: Record[0]> = + { + high: "green", + med: "yellow", + low: "red", + }; + // Status Formatting const STATUS_ICONS: Record = { - resolved: green("✓"), - unresolved: yellow("●"), - ignored: muted("−"), + resolved: colorTag("green", "✓"), + unresolved: colorTag("yellow", "●"), + ignored: colorTag("muted", "−"), }; const STATUS_LABELS: Record = { - resolved: `${green("✓")} Resolved`, - unresolved: `${yellow("●")} Unresolved`, - ignored: `${muted("−")} Ignored`, + resolved: `${colorTag("green", "✓")} Resolved`, + unresolved: `${colorTag("yellow", "●")} Unresolved`, + ignored: `${colorTag("muted", "−")} Ignored`, }; /** Maximum features to display before truncating with "... and N more" */ @@ -179,22 +191,15 @@ function formatFeaturesMarkdown(features: string[] | undefined): string { * Get status icon for an issue status */ export function formatStatusIcon(status: string | undefined): string { - if (!status) { - return statusColor("●", status); - } - return STATUS_ICONS[status as IssueStatus] ?? statusColor("●", status); + return STATUS_ICONS[status as IssueStatus] ?? colorTag("yellow", "●"); } /** * Get full status label for an issue status */ export function formatStatusLabel(status: string | undefined): string { - if (!status) { - return `${statusColor("●", status)} Unknown`; - } return ( - STATUS_LABELS[status as IssueStatus] ?? - `${statusColor("●", status)} Unknown` + STATUS_LABELS[status as IssueStatus] ?? `${colorTag("yellow", "●")} Unknown` ); } @@ -407,8 +412,12 @@ export function writeIssueTable( const columns: Column[] = [ { header: "LEVEL", - value: ({ issue }) => - levelColor((issue.level ?? "unknown").toUpperCase(), issue.level), + value: ({ issue }) => { + const level = (issue.level ?? "unknown").toLowerCase(); + const tag = LEVEL_TAGS[level]; + const label = level.toUpperCase(); + return tag ? colorTag(tag, label) : label; + }, }, ]; @@ -446,7 +455,8 @@ export function writeIssueTable( const text = formatFixability(issue.seerFixabilityScore); const score = issue.seerFixabilityScore; if (text && score !== null && score !== undefined) { - return fixabilityColor(text, getSeerFixabilityLabel(score)); + const tier = getSeerFixabilityLabel(score); + return colorTag(FIXABILITY_TAGS[tier], text); } return ""; }, @@ -490,7 +500,9 @@ export function formatIssueDetails(issue: SentryIssue): string { ) { const tier = getSeerFixabilityLabel(issue.seerFixabilityScore); const fixDetail = formatFixabilityDetail(issue.seerFixabilityScore); - rows.push(`| **Fixability** | ${fixabilityColor(fixDetail, tier)} |`); + rows.push( + `| **Fixability** | ${colorTag(FIXABILITY_TAGS[tier], fixDetail)} |` + ); } let levelLine = issue.level ?? "unknown"; diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index db122974..18341500 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -6,8 +6,8 @@ import type { DetailedSentryLog, SentryLog } from "../../types/index.js"; import { buildTraceUrl } from "../sentry-urls.js"; -import { cyan, muted, red, yellow } from "./colors.js"; import { + colorTag, divider, escapeMarkdownCell, escapeMarkdownInline, @@ -17,31 +17,32 @@ import { renderMarkdown, } from "./markdown.js"; -/** Color functions for log severity levels */ -const SEVERITY_COLORS: Record string> = { - fatal: red, - error: red, - warning: yellow, - warn: yellow, - info: cyan, - debug: muted, - trace: muted, +/** Markdown color tag names for log severity levels */ +const SEVERITY_TAGS: Record = { + fatal: "red", + error: "red", + warning: "yellow", + warn: "yellow", + info: "cyan", + debug: "muted", + trace: "muted", }; /** Column headers for the streaming log table */ const LOG_TABLE_COLS = ["Timestamp", "Level", "Message"] as const; /** - * Format severity level with appropriate color. + * Format severity level with appropriate color tag. * Pads to 7 characters for alignment (longest: "warning"). * * @param severity - The log severity level - * @returns Colored and padded severity string + * @returns Markdown color-tagged and padded severity string */ function formatSeverity(severity: string | null | undefined): string { const level = (severity ?? "info").toLowerCase(); - const colorFn = SEVERITY_COLORS[level] ?? ((s: string) => s); - return colorFn(level.toUpperCase().padEnd(7)); + const tag = SEVERITY_TAGS[level]; + const label = level.toUpperCase().padEnd(7); + return tag ? colorTag(tag as Parameters[0], label) : label; } /** @@ -118,15 +119,16 @@ export function formatLogTable(logs: SentryLog[]): string { } /** - * Format severity level with color for detailed view (not padded). + * Format severity level with color tag for detailed view (not padded). * * @param severity - The log severity level - * @returns Colored severity string + * @returns Markdown color-tagged severity string */ function formatSeverityLabel(severity: string | null | undefined): string { const level = (severity ?? "info").toLowerCase(); - const colorFn = SEVERITY_COLORS[level] ?? ((s: string) => s); - return colorFn(level.toUpperCase()); + const tag = SEVERITY_TAGS[level]; + const label = level.toUpperCase(); + return tag ? colorTag(tag as Parameters[0], label) : label; } /** diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 711c4d18..1a00a4be 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -171,12 +171,78 @@ export function divider(width = 80): string { /** Sentinel-inspired color palette */ const COLORS = { + red: "#fe4144", + green: "#83da90", yellow: "#FDB81B", blue: "#226DFC", + magenta: "#FF45A8", cyan: "#79B8FF", muted: "#898294", } as const; +/** + * Semantic HTML color tags supported in markdown strings. + * + * Formatters can embed `text`, `text`, etc. in + * any markdown string and the custom renderer will apply the corresponding + * ANSI color. In plain (non-TTY) mode the tags are stripped, leaving only + * the inner text. + * + * Supported tags: red, green, yellow, blue, magenta, cyan, muted + */ +const COLOR_TAGS: Record string> = { + red: (t) => chalk.hex(COLORS.red)(t), + green: (t) => chalk.hex(COLORS.green)(t), + yellow: (t) => chalk.hex(COLORS.yellow)(t), + blue: (t) => chalk.hex(COLORS.blue)(t), + magenta: (t) => chalk.hex(COLORS.magenta)(t), + cyan: (t) => chalk.hex(COLORS.cyan)(t), + muted: (t) => chalk.hex(COLORS.muted)(t), +}; + +/** + * Wrap text in a semantic color tag for use in markdown strings. + * + * In TTY mode the tag is rendered as an ANSI color by the custom renderer. + * In plain mode the tag is stripped and only the inner text is emitted. + * + * @example + * colorTag("red", "ERROR") // → "ERROR" + * colorTag("green", "✓") // → "" + */ +export function colorTag(tag: keyof typeof COLOR_TAGS, text: string): string { + return `<${tag}>${text}`; +} + +// Pre-compiled regexes for HTML color tag parsing (module-level for performance) +const RE_OPEN_TAG = /^<([a-z]+)>$/i; +const RE_CLOSE_TAG = /^<\/([a-z]+)>$/i; +const RE_SELF_TAG = /^<([a-z]+)>([\s\S]*?)<\/\1>$/i; + +/** + * Render an inline HTML token as a color-tagged string. + * + * Handles self-contained `text` forms. Bare open/close + * tags are dropped (marked emits them as separate tokens; the + * self-contained form is produced by `colorTag()`). + */ +function renderHtmlToken(raw: string): string { + const trimmed = raw.trim(); + if (RE_OPEN_TAG.test(trimmed) || RE_CLOSE_TAG.test(trimmed)) { + return ""; + } + const m = RE_SELF_TAG.exec(trimmed); + if (m) { + const tagName = m[1]; + const inner = m[2]; + if (tagName !== undefined && inner !== undefined) { + const colorFn = COLOR_TAGS[tagName.toLowerCase()]; + return colorFn ? colorFn(inner) : inner; + } + } + return ""; +} + /** * Syntax-highlight a code block. Falls back to uniform yellow if the * language is unknown or highlighting fails. @@ -243,8 +309,10 @@ function renderInline(tokens: Token[]): string { return renderInline((token as Tokens.Text).tokens ?? []); } return (token as Tokens.Text).text; - case "html": - return ""; // Strip inline HTML + case "html": { + const raw = (token as Tokens.HTML).raw ?? (token as Tokens.HTML).text; + return renderHtmlToken(raw); + } default: return (token as { raw?: string }).raw ?? ""; } diff --git a/test/lib/formatters/human.utils.test.ts b/test/lib/formatters/human.utils.test.ts index f44e5d7b..110fbdcd 100644 --- a/test/lib/formatters/human.utils.test.ts +++ b/test/lib/formatters/human.utils.test.ts @@ -24,10 +24,10 @@ import { } from "../../../src/lib/formatters/human.js"; import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; -// Helper to strip ANSI codes for content testing +// Helper to strip ANSI codes and markdown color tags for content testing function stripAnsi(str: string): string { // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars - return str.replace(/\x1b\[[0-9;]*m/g, ""); + return str.replace(/\x1b\[[0-9;]*m/g, "").replace(/<\/?[a-z]+>/g, ""); } // Status Formatting From 85ff8460ea11a93b5559ea0e67c0a00415c214d1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 16:13:28 +0000 Subject: [PATCH 33/52] fix(formatters): restore cross-org alias slash extraction in formatShortIdWithAlias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The o1/d cross-org collision alias format was dropped during the markdown refactor. formatShortIdWithAlias now extracts the project part after '/' before matching against short ID segments, restoring highlight for e.g. DASHBOARD-A3 with alias 'o1/d' → CLI-**D**ASHBOARD-**A3**. --- src/lib/formatters/human.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 7cab21cc..665fd0b4 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -301,7 +301,13 @@ function formatShortIdWithAlias( shortId: string, projectAlias: string ): string | null { - const aliasUpper = projectAlias.toUpperCase(); + // Extract the project part of the alias — cross-org collision aliases use + // the format "o1/d" where only "d" should match against the short ID parts. + const aliasPart = projectAlias.includes("/") + ? (projectAlias.split("/").pop() ?? projectAlias) + : projectAlias; + + const aliasUpper = aliasPart.toUpperCase(); const aliasLen = aliasUpper.length; const parts = shortId.split("-"); From 47832a8de51770c4330da77694c17ef3407a99fd Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 18:09:29 +0000 Subject: [PATCH 34/52] test(formatters): add coverage for text-table, markdown blocks/inline, colors, table plain mode New test files: - text-table.test.ts: 27 tests covering renderTextTable (border styles, alignment, column fitting proportional/balanced, cell wrapping, ANSI-aware width, header separator, multi-column structure) - colors.test.ts: 15 tests for statusColor, levelColor, fixabilityColor, terminalLink Expanded existing files: - markdown.test.ts: +33 tests for colorTag, escapeMarkdownInline, safeCodeSpan, divider, renderMarkdown blocks (headings, code, blockquote, lists, hr, tables), renderInlineMarkdown tokens (italic, links, strikethrough, color tags, unknown tags) - table.test.ts: +2 tests for plain-mode markdown table output Fix: renderInline now handles paired color tags that marked emits as separate html tokens (, text, ) by buffering inner tokens until the close tag and applying the color function. --- src/lib/formatters/markdown.ts | 124 ++++++++---- test/lib/formatters/colors.test.ts | 130 +++++++++++++ test/lib/formatters/markdown.test.ts | 242 +++++++++++++++++++++++ test/lib/formatters/table.test.ts | 59 ++++++ test/lib/formatters/text-table.test.ts | 258 +++++++++++++++++++++++++ 5 files changed, 776 insertions(+), 37 deletions(-) create mode 100644 test/lib/formatters/colors.test.ts create mode 100644 test/lib/formatters/text-table.test.ts diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 1a00a4be..5be6f9c4 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -273,51 +273,101 @@ function flattenInline(token: Token): Token[] { return [token]; } +/** + * Render a single inline token to an ANSI string. + */ +function renderOneInline(token: Token): string { + switch (token.type) { + case "strong": + return chalk.bold(renderInline((token as Tokens.Strong).tokens)); + case "em": + return chalk.italic(renderInline((token as Tokens.Em).tokens)); + case "codespan": + return chalk.hex(COLORS.yellow)((token as Tokens.Codespan).text); + case "link": { + const link = token as Tokens.Link; + const linkText = renderInline(link.tokens); + const styled = chalk.hex(COLORS.blue)(linkText); + return link.href ? terminalLink(styled, link.href) : styled; + } + case "del": + return chalk.dim.gray.strikethrough( + renderInline((token as Tokens.Del).tokens) + ); + case "br": + return "\n"; + case "escape": + return (token as Tokens.Escape).text; + case "text": + if ("tokens" in token && (token as Tokens.Text).tokens) { + return renderInline((token as Tokens.Text).tokens ?? []); + } + return (token as Tokens.Text).text; + case "html": { + const raw = (token as Tokens.HTML).raw ?? (token as Tokens.HTML).text; + return renderHtmlToken(raw); + } + default: + return (token as { raw?: string }).raw ?? ""; + } +} + /** * Render an array of inline tokens into an ANSI-styled string. * - * Handles: strong, em, codespan, link, text, br, del, escape, html. + * Handles paired color tags (`\u2026`) that `marked` emits as + * separate `html` tokens (open, inner tokens, close). Buffers inner + * tokens until the matching close tag, then applies the color function. + * + * Also handles: strong, em, codespan, link, text, br, del, escape. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: paired color tag buffering function renderInline(tokens: Token[]): string { - return tokens - .map((token) => { - switch (token.type) { - case "strong": - return chalk.bold(renderInline((token as Tokens.Strong).tokens)); - case "em": - return chalk.italic(renderInline((token as Tokens.Em).tokens)); - case "codespan": - return chalk.hex(COLORS.yellow)((token as Tokens.Codespan).text); - case "link": { - const link = token as Tokens.Link; - const linkText = renderInline(link.tokens); - const styled = chalk.hex(COLORS.blue)(linkText); - return link.href ? terminalLink(styled, link.href) : styled; - } - case "del": - return chalk.dim.gray.strikethrough( - renderInline((token as Tokens.Del).tokens) - ); - case "br": - return "\n"; - case "escape": - return (token as Tokens.Escape).text; - case "text": - // Text tokens may themselves contain sub-tokens (e.g. from - // autolinked URLs or inline markup inside list items) - if ("tokens" in token && (token as Tokens.Text).tokens) { - return renderInline((token as Tokens.Text).tokens ?? []); + const parts: string[] = []; + let i = 0; + + while (i < tokens.length) { + const token = tokens[i] as Token; + + // Check for color tag open: , , etc. + if (token.type === "html") { + const raw = ( + (token as Tokens.HTML).raw ?? (token as Tokens.HTML).text + ).trim(); + const openMatch = RE_OPEN_TAG.exec(raw); + if (openMatch) { + const tagName = (openMatch[1] ?? "").toLowerCase(); + const colorFn = COLOR_TAGS[tagName]; + if (colorFn) { + // Collect inner tokens until matching + const closeTag = ``; + const inner: Token[] = []; + i += 1; + while (i < tokens.length) { + const t = tokens[i] as Token; + if ( + t.type === "html" && + ((t as Tokens.HTML).raw ?? (t as Tokens.HTML).text) + .trim() + .toLowerCase() === closeTag.toLowerCase() + ) { + i += 1; // consume close tag + break; + } + inner.push(t); + i += 1; } - return (token as Tokens.Text).text; - case "html": { - const raw = (token as Tokens.HTML).raw ?? (token as Tokens.HTML).text; - return renderHtmlToken(raw); + parts.push(colorFn(renderInline(inner))); + continue; } - default: - return (token as { raw?: string }).raw ?? ""; } - }) - .join(""); + } + + parts.push(renderOneInline(token)); + i += 1; + } + + return parts.join(""); } // ──────────────────────── Block token rendering ────────────────────── diff --git a/test/lib/formatters/colors.test.ts b/test/lib/formatters/colors.test.ts new file mode 100644 index 00000000..1365b395 --- /dev/null +++ b/test/lib/formatters/colors.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for terminal color utilities. + * + * Covers: statusColor, levelColor, fixabilityColor, terminalLink, + * and the base color functions. + */ + +import { describe, expect, test } from "bun:test"; +import chalk from "chalk"; +import { + fixabilityColor, + levelColor, + statusColor, + terminalLink, +} from "../../../src/lib/formatters/colors.js"; + +// Force chalk colors even in test environment +chalk.level = 3; + +/** Strip ANSI escape codes for content assertions */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("statusColor", () => { + test("resolved → green-styled text", () => { + const result = statusColor("text", "resolved"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("unresolved → yellow-styled text", () => { + const result = statusColor("text", "unresolved"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("ignored → muted-styled text", () => { + const result = statusColor("text", "ignored"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("undefined defaults to unresolved styling", () => { + const result = statusColor("text", undefined); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("RESOLVED works case-insensitively", () => { + const result = statusColor("text", "RESOLVED"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); +}); + +describe("levelColor", () => { + test("fatal → colored text", () => { + const result = levelColor("text", "fatal"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("error → colored text", () => { + const result = levelColor("text", "error"); + expect(result).toContain("\x1b["); + }); + + test("warning → colored text", () => { + const result = levelColor("text", "warning"); + expect(result).toContain("\x1b["); + }); + + test("info → colored text", () => { + const result = levelColor("text", "info"); + expect(result).toContain("\x1b["); + }); + + test("debug → colored text", () => { + const result = levelColor("text", "debug"); + expect(result).toContain("\x1b["); + }); + + test("unknown level returns uncolored text", () => { + const result = levelColor("text", "unknown"); + expect(result).toBe("text"); + }); + + test("undefined returns uncolored text", () => { + const result = levelColor("text", undefined); + expect(result).toBe("text"); + }); +}); + +describe("fixabilityColor", () => { + test("high → green-styled text", () => { + const result = fixabilityColor("text", "high"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("med → yellow-styled text", () => { + const result = fixabilityColor("text", "med"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("low → red-styled text", () => { + const result = fixabilityColor("text", "low"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); +}); + +describe("terminalLink", () => { + test("wraps text in OSC 8 escape sequences", () => { + const result = terminalLink("click me", "https://example.com"); + expect(result).toContain("]8;;https://example.com"); + expect(result).toContain("click me"); + expect(result).toContain("]8;;"); + }); + + test("preserves display text", () => { + const result = terminalLink("display", "https://url.com"); + // biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 uses control chars + const stripped = result.replace(/\x1b\]8;;[^\x07]*\x07/g, ""); + expect(stripped).toBe("display"); + }); +}); diff --git a/test/lib/formatters/markdown.test.ts b/test/lib/formatters/markdown.test.ts index 72d36cb6..97121e17 100644 --- a/test/lib/formatters/markdown.test.ts +++ b/test/lib/formatters/markdown.test.ts @@ -8,13 +8,17 @@ import { describe, expect, test } from "bun:test"; import { + colorTag, + divider, escapeMarkdownCell, + escapeMarkdownInline, isPlainOutput, mdKvTable, mdRow, mdTableHeader, renderInlineMarkdown, renderMarkdown, + safeCodeSpan, } from "../../../src/lib/formatters/markdown.js"; // --------------------------------------------------------------------------- @@ -401,3 +405,241 @@ describe("mdKvTable", () => { expect(result).toContain("| **Only** | Row |"); }); }); + +// --------------------------------------------------------------------------- +// colorTag +// --------------------------------------------------------------------------- + +describe("colorTag", () => { + test("wraps text in HTML-style tag", () => { + expect(colorTag("red", "ERROR")).toBe("ERROR"); + }); + + test("works with all supported tags", () => { + for (const tag of [ + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "muted", + ] as const) { + const result = colorTag(tag, "text"); + expect(result).toBe(`<${tag}>text`); + } + }); + + test("rendered mode: strips color tags and preserves content", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = renderInlineMarkdown(colorTag("red", "ERROR")); + // Tags are consumed by the renderer (not present as raw HTML) + expect(result).not.toContain(""); + expect(result).not.toContain(""); + expect(stripAnsi(result)).toContain("ERROR"); + }); + }); + + test("plain mode: tags are stripped leaving bare text", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, true, () => { + const result = renderInlineMarkdown(colorTag("red", "ERROR")); + expect(result).toContain("ERROR"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// escapeMarkdownInline +// --------------------------------------------------------------------------- + +describe("escapeMarkdownInline", () => { + test("escapes underscores", () => { + expect(escapeMarkdownInline("hello_world")).toBe("hello\\_world"); + }); + + test("escapes asterisks", () => { + expect(escapeMarkdownInline("*bold*")).toBe("\\*bold\\*"); + }); + + test("escapes backticks", () => { + expect(escapeMarkdownInline("`code`")).toBe("\\`code\\`"); + }); + + test("escapes square brackets", () => { + expect(escapeMarkdownInline("[link]")).toBe("\\[link\\]"); + }); + + test("escapes backslashes", () => { + expect(escapeMarkdownInline("a\\b")).toBe("a\\\\b"); + }); + + test("returns unchanged string with no special chars", () => { + expect(escapeMarkdownInline("hello world")).toBe("hello world"); + }); +}); + +// --------------------------------------------------------------------------- +// safeCodeSpan +// --------------------------------------------------------------------------- + +describe("safeCodeSpan", () => { + test("wraps value in backticks", () => { + expect(safeCodeSpan("hello")).toBe("`hello`"); + }); + + test("replaces internal backticks with modifier letter", () => { + const result = safeCodeSpan("a`b"); + expect(result).not.toContain("`b"); + expect(result.startsWith("`")).toBe(true); + expect(result.endsWith("`")).toBe(true); + }); + + test("replaces pipe with unicode vertical bar", () => { + const result = safeCodeSpan("a|b"); + expect(result).not.toContain("|"); + expect(result).toContain("\u2502"); + }); + + test("replaces newlines with spaces", () => { + const result = safeCodeSpan("line1\nline2"); + expect(result).not.toContain("\n"); + expect(result).toContain("line1 line2"); + }); +}); + +// --------------------------------------------------------------------------- +// divider +// --------------------------------------------------------------------------- + +describe("divider", () => { + test("returns horizontal rule of default width", () => { + const result = divider(); + expect(stripAnsi(result)).toBe("\u2500".repeat(80)); + }); + + test("accepts custom width", () => { + const result = divider(40); + expect(stripAnsi(result)).toBe("\u2500".repeat(40)); + }); +}); + +// --------------------------------------------------------------------------- +// renderMarkdown: block-level rendering +// --------------------------------------------------------------------------- + +describe("renderMarkdown blocks (rendered mode)", () => { + function rendered(md: string): string { + let result = ""; + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + result = renderMarkdown(md); + }); + return result; + } + + test("renders headings", () => { + const result = rendered("## My Heading"); + expect(stripAnsi(result)).toContain("My Heading"); + }); + + test("renders paragraphs", () => { + const result = rendered("Hello paragraph text."); + expect(stripAnsi(result)).toContain("Hello paragraph text."); + }); + + test("renders code blocks with language", () => { + const result = rendered("```python\nprint('hello')\n```"); + expect(stripAnsi(result)).toContain("print"); + expect(stripAnsi(result)).toContain("hello"); + }); + + test("renders code blocks without language", () => { + const result = rendered("```\nsome code\n```"); + expect(stripAnsi(result)).toContain("some code"); + }); + + test("renders blockquotes", () => { + const result = rendered("> This is a quote"); + expect(stripAnsi(result)).toContain("This is a quote"); + }); + + test("renders unordered lists", () => { + const result = rendered("- Item A\n- Item B"); + expect(stripAnsi(result)).toContain("Item A"); + expect(stripAnsi(result)).toContain("Item B"); + }); + + test("renders ordered lists", () => { + const result = rendered("1. First\n2. Second"); + expect(stripAnsi(result)).toContain("First"); + expect(stripAnsi(result)).toContain("Second"); + }); + + test("renders horizontal rules", () => { + const result = rendered("---"); + expect(result).toContain("\u2500"); + }); + + test("renders markdown tables as box tables", () => { + const result = rendered("| A | B |\n|---|---|\n| 1 | 2 |"); + expect(stripAnsi(result)).toContain("A"); + expect(stripAnsi(result)).toContain("B"); + expect(stripAnsi(result)).toContain("1"); + expect(stripAnsi(result)).toContain("2"); + }); +}); + +// --------------------------------------------------------------------------- +// renderInlineMarkdown: inline token rendering +// --------------------------------------------------------------------------- + +describe("renderInlineMarkdown inline tokens (rendered mode)", () => { + function rendered(md: string): string { + let result = ""; + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + result = renderInlineMarkdown(md); + }); + return result; + } + + test("renders italic", () => { + const result = rendered("*italic text*"); + expect(stripAnsi(result)).toContain("italic text"); + // Should have ANSI codes + expect(result).not.toBe("*italic text*"); + }); + + test("renders links", () => { + const result = rendered("[click](https://example.com)"); + expect(stripAnsi(result)).toContain("click"); + }); + + test("renders strikethrough", () => { + const result = rendered("~~deleted~~"); + expect(stripAnsi(result)).toContain("deleted"); + }); + + test("renders color tags", () => { + const result = rendered("ERROR"); + // Tags are consumed by the renderer (not present as raw HTML) + expect(result).not.toContain(""); + expect(result).not.toContain(""); + expect(stripAnsi(result)).toContain("ERROR"); + }); + + test("unknown HTML tags are stripped", () => { + const result = rendered("fruit"); + expect(stripAnsi(result)).toContain("fruit"); + }); + + test("bare open tags are dropped", () => { + const result = rendered("before after"); + expect(stripAnsi(result)).toContain("before"); + expect(stripAnsi(result)).toContain("after"); + }); + + test("bare close tags are dropped", () => { + const result = rendered("before after"); + expect(stripAnsi(result)).toContain("before"); + expect(stripAnsi(result)).toContain("after"); + }); +}); diff --git a/test/lib/formatters/table.test.ts b/test/lib/formatters/table.test.ts index d8664262..089afe05 100644 --- a/test/lib/formatters/table.test.ts +++ b/test/lib/formatters/table.test.ts @@ -91,3 +91,62 @@ describe("writeTable", () => { expect(output).toContain("x"); }); }); + +// --------------------------------------------------------------------------- +// Plain-mode output (raw markdown tables) +// --------------------------------------------------------------------------- + +describe("writeTable (plain mode)", () => { + const saved = { + plain: process.env.SENTRY_PLAIN_OUTPUT, + noColor: process.env.NO_COLOR, + }; + + function withPlain(fn: () => void): void { + process.env.SENTRY_PLAIN_OUTPUT = "1"; + process.env.NO_COLOR = undefined; + try { + fn(); + } finally { + if (saved.plain !== undefined) { + process.env.SENTRY_PLAIN_OUTPUT = saved.plain; + } else { + delete process.env.SENTRY_PLAIN_OUTPUT; + } + if (saved.noColor !== undefined) { + process.env.NO_COLOR = saved.noColor; + } else { + delete process.env.NO_COLOR; + } + } + } + + test("emits raw markdown table", () => { + withPlain(() => { + const write = mock(() => true); + writeTable( + { write }, + [{ name: "alice", count: 1, status: "ok" }], + columns + ); + const output = write.mock.calls.map((c) => c[0]).join(""); + // Should contain pipe-delimited markdown format + expect(output).toContain("|"); + expect(output).toContain("NAME"); + expect(output).toContain("alice"); + }); + }); + + test("escapes pipe characters in cell values", () => { + withPlain(() => { + const cols: Column<{ v: string }>[] = [ + { header: "VAL", value: (r) => r.v }, + ]; + const write = mock(() => true); + writeTable({ write }, [{ v: "a|b" }], cols); + const output = write.mock.calls.map((c) => c[0]).join(""); + // Pipe should be escaped + expect(output).toContain("a\\|b"); + }); + }); +}); diff --git a/test/lib/formatters/text-table.test.ts b/test/lib/formatters/text-table.test.ts new file mode 100644 index 00000000..033f013f --- /dev/null +++ b/test/lib/formatters/text-table.test.ts @@ -0,0 +1,258 @@ +/** + * Tests for the ANSI-aware text table renderer. + * + * Covers: renderTextTable, column fitting (proportional + balanced), + * cell wrapping, alignment, border styles, and edge cases. + */ + +import { describe, expect, test } from "bun:test"; +import chalk from "chalk"; +import { renderTextTable } from "../../../src/lib/formatters/text-table.js"; + +// Force chalk colors even in test (non-TTY) environment +chalk.level = 3; + +describe("renderTextTable", () => { + describe("basic rendering", () => { + test("empty headers returns empty string", () => { + expect(renderTextTable([], [])).toBe(""); + }); + + test("renders single-column table", () => { + const out = renderTextTable(["Name"], [["Alice"], ["Bob"]]); + expect(out).toContain("Name"); + expect(out).toContain("Alice"); + expect(out).toContain("Bob"); + expect(out.endsWith("\n")).toBe(true); + }); + + test("renders multi-column table", () => { + const out = renderTextTable( + ["ID", "Name", "Role"], + [ + ["1", "Alice", "Admin"], + ["2", "Bob", "User"], + ] + ); + expect(out).toContain("ID"); + expect(out).toContain("Name"); + expect(out).toContain("Role"); + expect(out).toContain("Alice"); + expect(out).toContain("Admin"); + expect(out).toContain("Bob"); + expect(out).toContain("User"); + }); + + test("renders header-only table (no data rows)", () => { + const out = renderTextTable(["A", "B"], []); + expect(out).toContain("A"); + expect(out).toContain("B"); + expect(out.endsWith("\n")).toBe(true); + }); + }); + + describe("border styles", () => { + test("rounded (default) uses curved corners", () => { + const out = renderTextTable(["X"], [["1"]]); + expect(out).toContain("\u256d"); + expect(out).toContain("\u256e"); + expect(out).toContain("\u2570"); + expect(out).toContain("\u256f"); + }); + + test("single uses square corners", () => { + const out = renderTextTable(["X"], [["1"]], { borderStyle: "single" }); + expect(out).toContain("\u250c"); + expect(out).toContain("\u2510"); + expect(out).toContain("\u2514"); + expect(out).toContain("\u2518"); + }); + + test("heavy uses heavy corners", () => { + const out = renderTextTable(["X"], [["1"]], { borderStyle: "heavy" }); + expect(out).toContain("\u250f"); + expect(out).toContain("\u2513"); + expect(out).toContain("\u2517"); + expect(out).toContain("\u251b"); + }); + + test("double uses double corners", () => { + const out = renderTextTable(["X"], [["1"]], { borderStyle: "double" }); + expect(out).toContain("\u2554"); + expect(out).toContain("\u2557"); + expect(out).toContain("\u255a"); + expect(out).toContain("\u255d"); + }); + }); + + describe("header separator", () => { + test("includes separator by default when data rows present", () => { + const out = renderTextTable(["H"], [["d"]]); + expect(out).toContain("\u251c"); // ├ + expect(out).toContain("\u2524"); // ┤ + }); + + test("headerSeparator: false omits separator", () => { + const out = renderTextTable(["H"], [["d"]], { headerSeparator: false }); + expect(out).not.toContain("\u251c"); + expect(out).not.toContain("\u2524"); + }); + }); + + describe("alignment", () => { + test("right-aligned column pads text on the left", () => { + const out = renderTextTable(["Amount"], [["42"]], { + alignments: ["right"], + maxWidth: 40, + }); + const lines = out.split("\n"); + const dataLine = lines.find((l) => l.includes("42")); + expect(dataLine).toBeDefined(); + // Right-aligned: spaces before the value + const cellContent = dataLine!.split("\u2502")[1] ?? ""; + const trimmed = cellContent.trimStart(); + expect(cellContent.length).toBeGreaterThan(trimmed.length); + }); + + test("center-aligned column centers text", () => { + const out = renderTextTable(["Title"], [["Hi"]], { + alignments: ["center"], + maxWidth: 40, + }); + const lines = out.split("\n"); + const dataLine = lines.find((l) => l.includes("Hi")); + expect(dataLine).toBeDefined(); + }); + + test("default alignment is left", () => { + const out = renderTextTable(["Name"], [["A"]], { maxWidth: 40 }); + expect(out).toContain("A"); + }); + }); + + describe("column fitting", () => { + test("columns that fit naturally keep intrinsic widths", () => { + const out = renderTextTable(["A", "B"], [["x", "y"]], { maxWidth: 200 }); + expect(out).toContain("A"); + expect(out).toContain("B"); + }); + + test("proportional fitter shrinks wide columns more", () => { + const out = renderTextTable( + ["Short", "This is a very long header that needs shrinking"], + [["a", "b"]], + { maxWidth: 30, columnFitter: "proportional" } + ); + // Content is present (may be wrapped) + expect(out.length).toBeGreaterThan(0); + expect(out.endsWith("\n")).toBe(true); + expect(out.endsWith("\n")).toBe(true); + }); + + test("balanced fitter distributes shrink more evenly", () => { + const out = renderTextTable( + ["Short", "This is a very long header that needs shrinking"], + [["a", "b"]], + { maxWidth: 30, columnFitter: "balanced" } + ); + // Content is present (may be wrapped) + expect(out.length).toBeGreaterThan(0); + expect(out.endsWith("\n")).toBe(true); + expect(out.endsWith("\n")).toBe(true); + }); + + test("very narrow maxWidth still produces valid table", () => { + const out = renderTextTable( + ["Header One", "Header Two", "Header Three"], + [["data1", "data2", "data3"]], + { maxWidth: 15 } + ); + expect(out.length).toBeGreaterThan(0); + expect(out.endsWith("\n")).toBe(true); + }); + + test("proportional and balanced produce different layouts", () => { + const headers = ["A", "This is a much wider column"]; + const rows = [["x", "y"]]; + const prop = renderTextTable(headers, rows, { + maxWidth: 25, + columnFitter: "proportional", + }); + const bal = renderTextTable(headers, rows, { + maxWidth: 25, + columnFitter: "balanced", + }); + // Both should be valid tables but may differ in column widths + expect(prop).toContain("A"); + expect(bal).toContain("A"); + }); + }); + + describe("cell wrapping", () => { + test("long cell values wrap to multiple lines", () => { + const out = renderTextTable( + ["Name"], + [["This is a very long cell value that should wrap"]], + { maxWidth: 20 } + ); + const dataLines = out + .split("\n") + .filter((l) => l.includes("\u2502") && !l.includes("Name")); + expect(dataLines.length).toBeGreaterThan(1); + }); + }); + + describe("ANSI-aware rendering", () => { + test("preserves ANSI codes in cell values", () => { + const colored = chalk.red("ERROR"); + const out = renderTextTable(["Status"], [[colored]], { maxWidth: 40 }); + expect(out).toContain("\x1b["); + expect(out).toContain("ERROR"); + }); + + test("column width computed from visual width not byte length", () => { + const colored = chalk.red("Hi"); + const plain = "Hi"; + const outColored = renderTextTable(["H"], [[colored]], { maxWidth: 40 }); + const outPlain = renderTextTable(["H"], [[plain]], { maxWidth: 40 }); + const hzColored = (outColored.match(/\u2500/g) ?? []).length; + const hzPlain = (outPlain.match(/\u2500/g) ?? []).length; + expect(hzColored).toBe(hzPlain); + }); + }); + + describe("cellPadding", () => { + test("cellPadding: 0 produces tighter table", () => { + const tight = renderTextTable(["A"], [["x"]], { cellPadding: 0 }); + const padded = renderTextTable(["A"], [["x"]], { cellPadding: 2 }); + const tightWidth = (tight.split("\n")[0] ?? "").length; + const paddedWidth = (padded.split("\n")[0] ?? "").length; + expect(tightWidth).toBeLessThan(paddedWidth); + }); + }); + + describe("multi-column structure", () => { + test("columns are separated by vertical border character", () => { + const out = renderTextTable(["A", "B", "C"], [["1", "2", "3"]]); + const dataLines = out + .split("\n") + .filter((l) => l.includes("1") && l.includes("2") && l.includes("3")); + expect(dataLines.length).toBeGreaterThan(0); + const pipeCount = (dataLines[0] ?? "").split("\u2502").length - 1; + expect(pipeCount).toBe(4); // 3 columns = 4 borders + }); + + test("top border has T-junctions between columns", () => { + const out = renderTextTable(["A", "B", "C"], [["1", "2", "3"]]); + const topLine = out.split("\n")[0] ?? ""; + expect(topLine).toContain("\u252c"); // ┬ + }); + + test("bottom border has inverted T-junctions", () => { + const out = renderTextTable(["A", "B", "C"], [["1", "2", "3"]]); + const lines = out.split("\n").filter((l) => l.length > 0); + const bottomLine = lines.at(-1) ?? ""; + expect(bottomLine).toContain("\u2534"); // ┴ + }); + }); +}); From d4681724594238eae26a65dca6feac21d127f5da Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 19:57:47 +0000 Subject: [PATCH 35/52] fix(table): add truncate and minWidths options to prevent row wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue list table had each row spanning 2+ lines because the balanced column fitter shrunk the SHORT ID column below the text width when terminals were narrower than ~114 columns. Changes: - Add truncate option to TextTableOptions: cells clip to 1 line with '…' instead of wrapping to multiple lines - Add minWidths option to TextTableOptions: per-column minimum content widths that the fitters respect as floors during shrinking - Add minWidth and truncate fields to Column in table.ts, passed through to renderTextTable via writeTable - Set minWidth: 20 on the SHORT ID column in writeIssueTable to prevent short IDs from being squeezed; TITLE absorbs the shrink instead - Enable truncate: true for the issue list table --- src/lib/formatters/human.ts | 5 +- src/lib/formatters/table.ts | 23 +++++- src/lib/formatters/text-table.ts | 108 ++++++++++++++++++------- test/lib/formatters/text-table.test.ts | 73 +++++++++++++++++ 4 files changed, 175 insertions(+), 34 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 665fd0b4..516788b8 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -438,6 +438,9 @@ export function writeIssueTable( columns.push( { header: "SHORT ID", + // Prevent the balanced fitter from squeezing short IDs — they are the + // primary identifier users copy for `sentry issue view `. + minWidth: 20, value: ({ issue, formatOptions }) => { const formatted = formatShortId(issue.shortId, formatOptions); if (issue.permalink) { @@ -473,7 +476,7 @@ export function writeIssueTable( } ); - writeTable(stdout, rows, columns); + writeTable(stdout, rows, columns, { truncate: true }); } /** diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index 2bab5e27..2fde8afb 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -29,6 +29,10 @@ export type Column = { value: (item: T) => string; /** Column alignment. Defaults to "left". */ align?: "left" | "right"; + /** Minimum content width. Column will not shrink below this. */ + minWidth?: number; + /** Truncate long values with "\u2026" instead of wrapping. @default false */ + truncate?: boolean; }; /** @@ -69,10 +73,17 @@ export function buildMarkdownTable( * @param items - Row data * @param columns - Column definitions (ordering determines display order) */ +/** Options for writeTable. */ +export type WriteTableOptions = { + /** Truncate cells to one line with "\u2026" instead of wrapping. @default false */ + truncate?: boolean; +}; + export function writeTable( stdout: Writer, items: T[], - columns: Column[] + columns: Column[], + options?: WriteTableOptions ): void { if (isPlainOutput()) { stdout.write(`${buildMarkdownTable(items, columns)}\n`); @@ -85,5 +96,13 @@ export function writeTable( ); const alignments: Alignment[] = columns.map((c) => c.align ?? "left"); - stdout.write(renderTextTable(headers, rows, { alignments })); + const minWidths = columns.map((c) => c.minWidth ?? 0); + + stdout.write( + renderTextTable(headers, rows, { + alignments, + minWidths, + truncate: options?.truncate, + }) + ); } diff --git a/src/lib/formatters/text-table.ts b/src/lib/formatters/text-table.ts index b47c1dd7..538f6325 100644 --- a/src/lib/formatters/text-table.ts +++ b/src/lib/formatters/text-table.ts @@ -34,6 +34,10 @@ export type TextTableOptions = { alignments?: Array; /** Whether to include a separator row after the header. @default true */ headerSeparator?: boolean; + /** Per-column minimum content widths. Columns will not shrink below these. */ + minWidths?: number[]; + /** Truncate cells to one line with "\u2026" instead of wrapping. @default false */ + truncate?: boolean; }; /** @@ -59,6 +63,8 @@ export function renderTextTable( maxWidth = process.stdout.columns || 80, alignments = [], headerSeparator = true, + minWidths = [], + truncate = false, } = options; const border = BorderChars[borderStyle]; @@ -68,33 +74,30 @@ export function renderTextTable( } // Measure intrinsic column widths from all content - const intrinsicWidths = measureIntrinsicWidths( - headers, - rows, - colCount, - cellPadding - ); + const intrinsicWidths = measureIntrinsicWidths(headers, rows, colCount, { + cellPadding, + minWidths, + }); // Fit columns to available width // Border overhead: outerLeft(1) + outerRight(1) + innerSeparators(colCount-1) const borderOverhead = 2 + (colCount - 1); const maxContentWidth = Math.max(colCount, maxWidth - borderOverhead); - const columnWidths = fitColumns( - intrinsicWidths, - maxContentWidth, + const columnWidths = fitColumns(intrinsicWidths, maxContentWidth, { cellPadding, - columnFitter - ); + fitter: columnFitter, + minWidths, + }); // Build all rows (header + optional separator + data rows) const allRows: string[][][] = []; // Header row - allRows.push(wrapRow(headers, columnWidths, cellPadding)); + allRows.push(wrapRow(headers, columnWidths, cellPadding, false)); // Data rows for (const row of rows) { - allRows.push(wrapRow(row, columnWidths, cellPadding)); + allRows.push(wrapRow(row, columnWidths, cellPadding, truncate)); } // Render the grid @@ -117,8 +120,9 @@ function measureIntrinsicWidths( headers: string[], rows: string[][], colCount: number, - cellPadding: number + ctx: { cellPadding: number; minWidths: number[] } ): number[] { + const { cellPadding, minWidths } = ctx; const pad = cellPadding * 2; const widths: number[] = []; @@ -134,8 +138,9 @@ function measureIntrinsicWidths( } } - // Minimum: padding + 1 char - widths.push(Math.max(maxW, pad + 1)); + // Minimum: padding + 1 char, or per-column minWidth + padding + const colMin = (minWidths[c] ?? 0) + pad; + widths.push(Math.max(maxW, pad + 1, colMin)); } return widths; @@ -150,9 +155,13 @@ function measureIntrinsicWidths( function fitColumns( intrinsicWidths: number[], maxContentWidth: number, - cellPadding: number, - fitter: "proportional" | "balanced" + ctx: { + cellPadding: number; + fitter: "proportional" | "balanced"; + minWidths: number[]; + } ): number[] { + const { cellPadding, fitter, minWidths } = ctx; const totalIntrinsic = intrinsicWidths.reduce((s, w) => s + w, 0); if (totalIntrinsic <= maxContentWidth) { @@ -160,9 +169,19 @@ function fitColumns( } if (fitter === "balanced") { - return fitBalanced(intrinsicWidths, maxContentWidth, cellPadding); + return fitBalanced( + intrinsicWidths, + maxContentWidth, + cellPadding, + minWidths + ); } - return fitProportional(intrinsicWidths, maxContentWidth, cellPadding); + return fitProportional( + intrinsicWidths, + maxContentWidth, + cellPadding, + minWidths + ); } /** @@ -174,17 +193,25 @@ function fitColumns( function fitProportional( widths: number[], target: number, - cellPadding: number + cellPadding: number, + minWidths: number[] = [] ): number[] { - const minWidth = 1 + cellPadding * 2; - const baseWidths = widths.map((w) => Math.max(minWidth, Math.floor(w))); + const globalMin = 1 + cellPadding * 2; + const colMins = widths.map((_, i) => + Math.max(globalMin, (minWidths[i] ?? 0) + cellPadding * 2) + ); + const baseWidths = widths.map((w, i) => + Math.max(colMins[i] ?? globalMin, Math.floor(w)) + ); const totalBase = baseWidths.reduce((s, w) => s + w, 0); if (totalBase <= target) { return baseWidths; } - const floorWidths = baseWidths.map((w) => Math.min(w, minWidth + 1)); + const floorWidths = baseWidths.map((w, i) => + Math.min(w, (colMins[i] ?? globalMin) + 1) + ); const floorTotal = floorWidths.reduce((s, w) => s + w, 0); const clampedTarget = Math.max(floorTotal, target); @@ -216,18 +243,26 @@ function fitProportional( function fitBalanced( widths: number[], target: number, - cellPadding: number + cellPadding: number, + minWidths: number[] = [] ): number[] { - const minWidth = 1 + cellPadding * 2; - const baseWidths = widths.map((w) => Math.max(minWidth, Math.floor(w))); + const globalMin = 1 + cellPadding * 2; + const colMins = widths.map((_, i) => + Math.max(globalMin, (minWidths[i] ?? 0) + cellPadding * 2) + ); + const baseWidths = widths.map((w, i) => + Math.max(colMins[i] ?? globalMin, Math.floor(w)) + ); const totalBase = baseWidths.reduce((s, w) => s + w, 0); if (totalBase <= target) { return baseWidths; } - const evenShare = Math.max(minWidth, Math.floor(target / baseWidths.length)); - const floorWidths = baseWidths.map((w) => Math.min(w, evenShare)); + const evenShare = Math.max(globalMin, Math.floor(target / baseWidths.length)); + const floorWidths = baseWidths.map((w, i) => + Math.min(w, Math.max(evenShare, colMins[i] ?? globalMin)) + ); const floorTotal = floorWidths.reduce((s, w) => s + w, 0); const clampedTarget = Math.max(floorTotal, target); @@ -338,7 +373,8 @@ function allocateShrink(params: ShrinkParams): number[] { function wrapRow( cells: string[], columnWidths: number[], - cellPadding: number + cellPadding: number, + truncate: boolean ): string[][] { const wrappedCells: string[][] = []; for (let c = 0; c < columnWidths.length; c++) { @@ -349,7 +385,17 @@ function wrapRow( continue; } const wrapped = wrapAnsi(text, contentWidth, { hard: true, trim: false }); - wrappedCells.push(wrapped.split("\n")); + const lines = wrapped.split("\n"); + if (truncate && lines.length > 1) { + // Re-wrap to contentWidth-1 so the ellipsis fits within the column + const shorter = wrapAnsi(text, Math.max(1, contentWidth - 1), { + hard: true, + trim: false, + }); + wrappedCells.push([`${shorter.split("\n")[0] ?? ""}\u2026`]); + } else { + wrappedCells.push(lines); + } } return wrappedCells; } diff --git a/test/lib/formatters/text-table.test.ts b/test/lib/formatters/text-table.test.ts index 033f013f..7ab0fe44 100644 --- a/test/lib/formatters/text-table.test.ts +++ b/test/lib/formatters/text-table.test.ts @@ -256,3 +256,76 @@ describe("renderTextTable", () => { }); }); }); + +describe("truncate option", () => { + test("truncates long cells to one line with ellipsis", () => { + const out = renderTextTable( + ["Name"], + [["This is a very long cell value that should be truncated"]], + { maxWidth: 20, truncate: true } + ); + const dataLines = out + .split("\n") + .filter( + (l) => + l.includes("\u2502") && + !l.includes("Name") && + !l.includes("\u2500") && + l.trim().length > 2 + ); + // Should be exactly 1 data line (not wrapped to multiple) + expect(dataLines.length).toBe(1); + // Should contain ellipsis + expect(dataLines[0]).toContain("\u2026"); + }); + + test("does not truncate short cells", () => { + const out = renderTextTable(["Name"], [["Hi"]], { + maxWidth: 40, + truncate: true, + }); + expect(out).toContain("Hi"); + expect(out).not.toContain("\u2026"); + }); + + test("headers are never truncated", () => { + const out = renderTextTable( + ["Very Long Header Name"], + [["This is an even longer cell value that should be truncated"]], + { maxWidth: 30, truncate: true } + ); + // Header should not have ellipsis (only data rows truncate) + const headerLine = out + .split("\n") + .find((l) => l.includes("Very Long Header")); + expect(headerLine).toBeDefined(); + expect(headerLine).not.toContain("\u2026"); + }); +}); + +describe("minWidths option", () => { + test("prevents column from shrinking below minimum", () => { + const out = renderTextTable( + ["Short", "Long Column Header"], + [["data", "other data"]], + { maxWidth: 25, minWidths: [10, 0] } + ); + // First column should maintain at least 10-char content width + // (10 + 2 padding = 12 total column width) + const dataLine = out.split("\n").find((l) => l.includes("data")); + expect(dataLine).toBeDefined(); + // The "Short" column should have enough space for "data" + padding + expect(out).toContain("data"); + }); + + test("TITLE column absorbs shrink when SHORT ID has minWidth", () => { + const out = renderTextTable( + ["ID", "TITLE"], + [["SPOTLIGHT-WEB-28", "Very long error message that gets truncated"]], + { maxWidth: 50, minWidths: [20, 0], truncate: true } + ); + expect(out).toContain("SPOTLIGHT-WEB-28"); + // TITLE should be truncated, not SHORT ID + expect(out).toContain("\u2026"); + }); +}); From 1dd980815f0e389936a0fdb76bf905a0ea209c03 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 27 Feb 2026 21:06:48 +0000 Subject: [PATCH 36/52] fix(table): use shrinkable: false for SHORT ID instead of blanket truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHORT ID values should never be truncated or shrunk — they are the primary identifier users copy for 'sentry issue view '. TITLE should show the full text, wrapping to multiple lines if needed. Replace the blanket truncate: true on the issue table with a shrinkable column option: - Add shrinkable?: boolean[] to TextTableOptions and Column - Non-shrinkable columns keep their intrinsic (measured) width - fitColumns separates fixed vs elastic columns: fixed columns are excluded from the fitting algorithm, elastic ones share the rest - Set shrinkable: false on the SHORT ID column in writeIssueTable - Remove truncate: true from writeIssueTable (TITLE wraps naturally) --- src/lib/formatters/human.ts | 8 ++--- src/lib/formatters/table.ts | 4 +++ src/lib/formatters/text-table.ts | 49 ++++++++++++++++++-------- test/lib/formatters/text-table.test.ts | 27 ++++++++++++++ 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 516788b8..b1406132 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -438,9 +438,9 @@ export function writeIssueTable( columns.push( { header: "SHORT ID", - // Prevent the balanced fitter from squeezing short IDs — they are the - // primary identifier users copy for `sentry issue view `. - minWidth: 20, + // Short IDs are the primary identifier users copy for + // `sentry issue view ` — never shrink or truncate them. + shrinkable: false, value: ({ issue, formatOptions }) => { const formatted = formatShortId(issue.shortId, formatOptions); if (issue.permalink) { @@ -476,7 +476,7 @@ export function writeIssueTable( } ); - writeTable(stdout, rows, columns, { truncate: true }); + writeTable(stdout, rows, columns); } /** diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index 2fde8afb..69ed89f6 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -31,6 +31,8 @@ export type Column = { align?: "left" | "right"; /** Minimum content width. Column will not shrink below this. */ minWidth?: number; + /** Whether this column can be shrunk when the table exceeds terminal width. @default true */ + shrinkable?: boolean; /** Truncate long values with "\u2026" instead of wrapping. @default false */ truncate?: boolean; }; @@ -97,11 +99,13 @@ export function writeTable( const alignments: Alignment[] = columns.map((c) => c.align ?? "left"); const minWidths = columns.map((c) => c.minWidth ?? 0); + const shrinkable = columns.map((c) => c.shrinkable ?? true); stdout.write( renderTextTable(headers, rows, { alignments, minWidths, + shrinkable, truncate: options?.truncate, }) ); diff --git a/src/lib/formatters/text-table.ts b/src/lib/formatters/text-table.ts index 538f6325..df272ba2 100644 --- a/src/lib/formatters/text-table.ts +++ b/src/lib/formatters/text-table.ts @@ -36,6 +36,8 @@ export type TextTableOptions = { headerSeparator?: boolean; /** Per-column minimum content widths. Columns will not shrink below these. */ minWidths?: number[]; + /** Per-column shrinkable flags. Non-shrinkable columns keep intrinsic width. */ + shrinkable?: boolean[]; /** Truncate cells to one line with "\u2026" instead of wrapping. @default false */ truncate?: boolean; }; @@ -64,6 +66,7 @@ export function renderTextTable( alignments = [], headerSeparator = true, minWidths = [], + shrinkable = [], truncate = false, } = options; @@ -87,6 +90,7 @@ export function renderTextTable( cellPadding, fitter: columnFitter, minWidths, + shrinkable, }); // Build all rows (header + optional separator + data rows) @@ -159,29 +163,46 @@ function fitColumns( cellPadding: number; fitter: "proportional" | "balanced"; minWidths: number[]; + shrinkable: boolean[]; } ): number[] { - const { cellPadding, fitter, minWidths } = ctx; + const { cellPadding, fitter, minWidths, shrinkable: shrinkFlags } = ctx; const totalIntrinsic = intrinsicWidths.reduce((s, w) => s + w, 0); if (totalIntrinsic <= maxContentWidth) { return intrinsicWidths; } - if (fitter === "balanced") { - return fitBalanced( - intrinsicWidths, - maxContentWidth, - cellPadding, - minWidths - ); - } - return fitProportional( - intrinsicWidths, - maxContentWidth, - cellPadding, - minWidths + // Separate fixed (non-shrinkable) and elastic (shrinkable) columns. + // Fixed columns keep their intrinsic width; elastic ones share the rest. + const isFixed = intrinsicWidths.map((_, i) => shrinkFlags[i] === false); + const fixedTotal = intrinsicWidths.reduce( + (s, w, i) => s + (isFixed[i] ? w : 0), + 0 ); + const elasticTarget = maxContentWidth - fixedTotal; + const elasticWidths = intrinsicWidths.filter((_, i) => !isFixed[i]); + const elasticMins = minWidths.filter((_, i) => !isFixed[i]); + + if (elasticWidths.length === 0 || elasticTarget <= 0) { + return intrinsicWidths; + } + + const fitFn = fitter === "balanced" ? fitBalanced : fitProportional; + const fitted = fitFn(elasticWidths, elasticTarget, cellPadding, elasticMins); + + // Merge fixed and fitted widths back into the original column order + const result: number[] = []; + let ei = 0; + for (let i = 0; i < intrinsicWidths.length; i++) { + if (isFixed[i]) { + result.push(intrinsicWidths[i] ?? 0); + } else { + result.push(fitted[ei] ?? 0); + ei += 1; + } + } + return result; } /** diff --git a/test/lib/formatters/text-table.test.ts b/test/lib/formatters/text-table.test.ts index 7ab0fe44..3bf62453 100644 --- a/test/lib/formatters/text-table.test.ts +++ b/test/lib/formatters/text-table.test.ts @@ -329,3 +329,30 @@ describe("minWidths option", () => { expect(out).toContain("\u2026"); }); }); + +describe("shrinkable option", () => { + test("non-shrinkable column keeps intrinsic width", () => { + const out = renderTextTable( + ["FIXED", "ELASTIC"], + [["SPOTLIGHT-WEB-28", "A very long title that should absorb all shrink"]], + { maxWidth: 50, shrinkable: [false, true] } + ); + // The FIXED column should show the full value without wrapping + expect(out).toContain("SPOTLIGHT-WEB-28"); + // Check no line has SPOTLIGHT-WEB-28 split across lines + const dataLines = out.split("\n").filter((l) => l.includes("SPOTLIGHT")); + expect(dataLines.length).toBe(1); + }); + + test("elastic column absorbs all shrink", () => { + const out = renderTextTable( + ["FIXED", "ELASTIC"], + [["keep-me", "shrink this very long text value please"]], + { maxWidth: 30, shrinkable: [false, true] } + ); + // FIXED column should be intact + expect(out).toContain("keep-me"); + const fixedLines = out.split("\n").filter((l) => l.includes("keep-me")); + expect(fixedLines.length).toBe(1); + }); +}); From 600c954b84d8621321cf5efe96d20b36b293b500 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 00:27:17 +0000 Subject: [PATCH 37/52] fix(formatters): address all PR review comments - Fix terminalLink OSC 8 hyperlinks: add missing \x1b ESC prefix and \x07 BEL terminator - Fix escapeMarkdownCell/escapeMarkdownInline: escape < and > to </> so user content with angle brackets (e.g. 'Expected ') is not silently dropped by marked's HTML token renderer - Strip color tags (text) in plain output mode so they don't leak as literal markup when piped/redirected - Fix mdKvTable: escape backslashes before replacing pipes, matching escapeMarkdownCell behavior - Fix streaming headers (formatLogsHeader/formatTracesHeader): emit proper markdown table header+separator in plain mode instead of Unicode divider - Escape issue titles in writeIssueTable with escapeMarkdownInline to prevent underscores/asterisks from rendering as emphasis - Differentiate h1/h2 (bold cyan) from h3+ (plain cyan) heading rendering instead of identical branches - Remove orphaned JSDoc comment for deleted writeIssueRows function - Type SEVERITY_TAGS precisely to eliminate unsafe cast at call sites - Fix .trim() on formatLogTable severity: use formatSeverityLabel (no padding) instead of trimming inside color tag - Update tests to match corrected behavior --- AGENTS.md | 131 +++++++++++++++++++++++++++ src/commands/issue/list.ts | 12 +-- src/lib/formatters/colors.ts | 7 +- src/lib/formatters/human.ts | 6 +- src/lib/formatters/log.ts | 18 +++- src/lib/formatters/markdown.ts | 65 ++++++++++--- src/lib/formatters/trace.ts | 8 ++ test/lib/formatters/log.test.ts | 4 +- test/lib/formatters/markdown.test.ts | 5 +- test/lib/formatters/trace.test.ts | 7 +- 10 files changed, 227 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb92cb28..e2fa466d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -709,3 +709,134 @@ mock.module("./some-module", () => ({ * **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. + + +## Long-term Knowledge + +### Architecture + + +* **@sentry/node-core pulls in @apm-js-collab/code-transformer (3.3MB) but it's tree-shaken**: The dependency chain \`@sentry/node\` → \`@sentry/node-core\` → \`@apm-js-collab/tracing-hooks\` → \`@apm-js-collab/code-transformer\` adds a 3.3MB input file to the esbuild bundle analysis. However, esbuild fully tree-shakes it out — it contributes 0 bytes to the final npm bundle output. This was verified by checking both the released 0.13.0 bundle and the current build: neither contains any \`code-transformer\` or \`apm-js-collab\` strings. Don't be alarmed by its presence in metafile inputs. + +* **GitHub Packages has no generic/raw file registry — only typed registries**: GitHub Packages only supports typed registries (Container/npm/Maven/NuGet/RubyGems) — there is no generic file store where you can upload a binary and get a download URL. For distributing arbitrary binaries: the Container registry (ghcr.io) via ORAS works but requires a 3-step download (token → manifest → blob). The npm registry at \`npm.pkg.github.com\` requires authentication even for public packages, making it unsuitable for install scripts. This means for binary distribution on GitHub without GitHub Releases, GHCR+ORAS is the only viable GitHub Packages option. + +* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: The \`cli.sentry.dev\` domain points to GitHub Pages for the getsentry/cli repo, confirmed by the CNAME file on the gh-pages branch containing \`cli.sentry.dev\`. The branch contains the Astro/Starlight docs site output plus the install script at \`/install\`. Craft's gh-pages target manages this branch, wiping and replacing all content on each stable release. The docs are built as a zip artifact named \`gh-pages.zip\` (matched by Craft's \`DEFAULT\_DEPLOY\_ARCHIVE\_REGEX: /^(?:.+-)?gh-pages\\.zip$/\`). + +* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The Sentry CLI npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses Node.js 22's built-in \`node:sqlite\` module. The \`require('node:sqlite')\` happens during module loading (via esbuild's inject option) — before any user code runs. package.json declares \`engines: { node: '>=22' }\` but pnpm/npm don't enforce this by default. A runtime version guard in the esbuild banner catches this early with a clear error message pointing users to either upgrade Node.js or use the standalone binary (\`curl -fsSL https://cli.sentry.dev/install | bash\`). + +* **parseSentryUrl does not handle subdomain-style SaaS URLs**: The URL parser in src/lib/sentry-url-parser.ts handles both path-based (\`/organizations/{org}/...\`) and subdomain-style (\`https://{org}.sentry.io/issues/123/\`) SaaS URLs. The \`matchSubdomainOrg()\` function extracts the org from the hostname when it ends with \`.sentry.io\`, supports \`/issues/{id}/\`, \`/issues/{id}/events/{eventId}/\`, and \`/traces/{traceId}/\` paths. Region subdomains (\`us\`, \`de\`) are filtered out by requiring org slugs to be longer than 2 characters. Confirmed against getsentry/sentry codebase: the subdomain IS the org slug directly (e.g., \`my-org.sentry.io\`), NOT a fixed prefix like \`o.sentry.io\`. The Sentry backend builds permalinks via \`organization.absolute\_url()\` → \`generate\_organization\_url(slug)\` using the \`system.organization-base-hostname\` template \`{slug}.sentry.io\` (src/sentry/organizations/absolute\_url.py:72-92). When customer domains are enabled (production SaaS), \`customer\_domain\_path()\` strips \`/organizations/{slug}/\` from paths. Region subdomains are filtered by Sentry's \`subdomain\_is\_region()\`, aligning with the \`org.length <= 2\` check. Self-hosted uses path-based: \`/organizations/{org\_slug}/issues/{id}/\`. + +* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution now uses a multi-step approach in \`resolveNumericIssue()\` (extracted from \`resolveIssue\` to reduce cognitive complexity). Resolution order: (1) \`resolveOrg({ cwd })\` tries DSN/env/config for org context, (2) if org found, uses \`getIssueInOrg(org, id)\` with region routing, (3) if no org, falls back to unscoped \`getIssue(id)\`, (4) extracts org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. The \`explicit-org-numeric\` case now uses \`getIssueInOrg(parsed.org, id)\` instead of the unscoped endpoint. \`getIssueInOrg\` was added to api-client.ts using the SDK's \`retrieveAnIssue\` with the standard \`getOrgSdkConfig + unwrapResult\` pattern. The \`resolveOrgAndIssueId\` wrapper (used by \`explain\`/\`plan\`) no longer throws "Organization is required" for bare numeric IDs when the permalink contains the org slug. + +* **Install script nightly channel support**: The curl install script (\`cli.sentry.dev/install\`) supports \`--channel nightly\`. For nightly: downloads the binary directly from the \`nightly\` release tag (no version.json fetch needed — just uses \`nightly\` as both download tag and display string). Passes \`--channel nightly\` to \`$binary cli setup --install --method curl --channel nightly\` so the channel is persisted in DB. Usage: \`curl -fsSL https://cli.sentry.dev/install | bash -s -- --channel nightly\`. The install script does NOT fetch version.json — that's only used by the upgrade/version-check flow to compare versions. + +### Decision + + +* **Nightly release: delete-then-upload instead of clobber for asset management**: The publish-nightly CI job deletes ALL existing release assets before uploading new ones, rather than using \`gh release upload --clobber\`. This ensures that if asset names change (e.g., removing a platform or renaming files), stale assets don't linger on the nightly release indefinitely. Pattern: \`gh release view nightly --json assets --jq '.assets\[].name' | while read -r name; do gh release delete-asset nightly "$name" --yes; done\` followed by \`gh release upload nightly \\` (no --clobber needed since assets were cleared). + +* **Raw markdown output for non-interactive terminals, rendered for TTY**: Design decision for getsentry/cli: output raw CommonMark markdown when stdout is not interactive (piped, redirected, CI), and only render through marked-terminal when a human is looking at it in a TTY. Detection: \`const isInteractive = process.stdout.isTTY\` — no \`process.env.CI\` check needed, since if a CI runner allocates a pseudo-TTY it can render the styled output fine. Override env vars with precedence: \`SENTRY\_PLAIN\_OUTPUT\` (most specific) > \`NO\_COLOR\` > auto-detect via \`isTTY\`. \`SENTRY\_PLAIN\_OUTPUT=1\` forces raw markdown even on TTY; \`SENTRY\_PLAIN\_OUTPUT=0\` forces rendered even when piped. \`NO\_COLOR\` (no-color.org standard) also triggers plain mode since rendered output is ANSI-colored. Chalk auto-disables colors when piped, so pre-embedded ANSI codes become plain text in raw mode. Output modes: \`--json\` → JSON (unchanged), TTY → rendered markdown via marked-terminal, non-TTY → raw CommonMark. For streaming formatters (log/trace row-by-row output), TTY keeps current ANSI-colored padded text for efficient incremental display, while non-TTY emits markdown table rows with header+separator on first write, producing valid markdown files when redirected. + +### Gotcha + + +* **git notes are lost on commit amend — must re-attach to new SHA**: Git notes are attached to a specific commit SHA. When you \`git commit --amend\`, the old commit is replaced with a new one (different SHA), and the note attached to the old SHA becomes orphaned. After amending, you must re-add the note to the new commit with \`git notes add\` targeting the new SHA. This also affects \`git push --force\` of notes refs — the remote note ref still points to the old SHA. + +* **pnpm overrides with version-range keys don't force upgrades of already-compatible resolutions**: pnpm overrides with version-range selectors like \`"minimatch@>=10.0.0 <10.2.1": ">=10.2.1"\` do NOT work as expected for forcing upgrades of transitive deps that already satisfy their parent's semver range. If a parent requests \`^10.1.1\` and pnpm resolves \`10.1.1\`, the override key \`>=10.0.0 <10.2.1\` should match but doesn't reliably force re-resolution — even with \`pnpm install --force\`. The workaround is a blanket override without a version selector: \`"minimatch": ">=10.2.1"\`. This is only safe when ALL consumers are on the same major version line (otherwise it's a breaking change). Verify first with \`pnpm why \\` that no other major versions exist in the tree before using a blanket override. + +* **pnpm overrides with >= can cross major versions — use ^ to constrain**: When using pnpm overrides to patch a transitive dependency vulnerability, \`"ajv@<6.14.0": ">=6.14.0"\` will resolve to the latest ajv (v8.x), not the latest 6.x. ajv v6 and v8 have incompatible APIs — this broke eslint (\`@eslint/eslintrc\` calls \`ajv\` v6 API, crashes with \`Cannot set properties of undefined (setting 'defaultMeta')\` on v8). Fix: use \`"ajv@<6.14.0": "^6.14.0"\` to constrain within the same major. This applies to any override where the target package has multiple major versions in the registry — always use \`^\` (or \`~\`) instead of \`>=\` to stay within the compatible major line. + +* **marked-terminal unconditionally imports cli-highlight and node-emoji — no tree-shaking possible**: marked-terminal has static top-level imports of \`cli-highlight\` (which pulls in highlight.js, ~570KB minified output) and \`node-emoji\` (which pulls in emojilib, ~208KB minified output) at lines 5-6 of its index.js. These are unconditional — there's no config option to disable them, and the \`emoji: false\` option only skips the emoji replacement function but doesn't prevent the import. esbuild cannot tree-shake static imports. This means any bundle including marked-terminal will grow by ~970KB (highlight.js + emojilib + parse5). To avoid this in a CLI bundle, you'd need to either: (1) write a custom marked renderer using only chalk, (2) fork marked-terminal with dynamic imports, or (3) use esbuild's \`external\` option (but then those packages must be available at runtime). + +* **pnpm-lock.yaml merge conflicts: regenerate don't manually merge**: When \`pnpm-lock.yaml\` has merge conflicts, never try to manually resolve the conflict markers. Instead: (1) \`git checkout --theirs pnpm-lock.yaml\` (or \`--ours\` depending on which package.json changes you want as base), (2) run \`pnpm install\` to regenerate the lockfile incorporating both sides' \`package.json\` changes (including overrides). This produces a clean lockfile that reflects the merged dependency state. Manual conflict resolution in lockfiles is error-prone and unnecessary since pnpm can regenerate it deterministically. + +* **git stash pop after merge can cause second conflict on same file**: When you stash local changes, merge, then \`git stash pop\`, the stash apply can create a NEW conflict on the same file that was just conflict-resolved in the merge. This happens because the stash was based on the pre-merge state and conflicts with the post-merge-resolution content. The resolution requires a second manual conflict resolution pass. To avoid: if stashed changes are lore/auto-generated content, consider just dropping the stash and re-running the generation tool after merge instead of popping. + +* **pnpm overrides become stale when dependency tree changes — audit with pnpm why**: pnpm overrides can become orphaned when the dependency tree changes. For example, removing a package that was the sole consumer of a transitive dep (like removing \`@sentry/typescript\` which pulled in \`tslint\` which was the only consumer of \`diff\`), or upgrading a package that switches to a differently-named dependency (like \`minimatch@10.2.4\` switching from \`@isaacs/brace-expansion\` to the unscoped \`brace-expansion\`). Orphaned overrides sit silently in package.json and could unexpectedly constrain versions if a future dependency reintroduces the package name. After removing packages or upgrading dependencies that change the transitive tree, audit overrides with \`pnpm why \\` to verify each override still has consumers in the resolved tree. Remove any that return empty results. + +* **ESM modules prevent vi.spyOn of child\_process.spawnSync — use test subclass pattern**: In Vitest with ESM, you cannot spy on exports from Node built-in modules like \`child\_process.spawnSync\` — it throws \`Cannot spy on export. Module namespace is not configurable in ESM\`. The workaround for testing code that calls \`spawnSync\` (like npm version checks) is to create a test subclass that overrides the method calling \`spawnSync\` and injects controllable values. Example: \`TestNpmTarget\` overrides \`checkRequirements()\` to set \`this.npmVersion\` from a static \`mockVersion\` field instead of calling \`spawnSync(npm, \['--version'])\`. This avoids the ESM limitation while keeping test isolation. \`vi.mock\` at module level is another option but affects all tests in the file. + +* **npm OIDC only works for publish — npm info/view still needs traditional auth**: Per npm docs: "OIDC authentication is currently limited to the publish operation. Other npm commands such as install, view, or access still require traditional authentication methods." This means \`npm info \ version\` (used by Craft's \`getLatestVersion()\`) cannot use OIDC tokens. For public packages this is fine — \`npm info\` works without auth. For private packages, a read-only \`NPM\_TOKEN\` is still needed alongside OIDC. Craft handles this by: using token auth for \`getLatestVersion()\` when a token is available, running unauthenticated otherwise, and warning + skipping version checks for private packages in OIDC mode without a token. + +* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` + +* **marked and marked-terminal must be devDependencies in bundled CLI projects**: In the getsentry/cli project, all npm packages used at runtime must be bundled by the build step (esbuild). Packages like \`marked\` and \`marked-terminal\` belong in \`devDependencies\`, not \`dependencies\`. The CI \`check:deps\` step enforces this — anything in \`dependencies\` that isn't a true runtime requirement (native addon, bin entry) will fail the check. This applies to all packages consumed only through the bundle. + +* **CodeQL flags incomplete markdown cell escaping — must escape backslash before pipe**: When escaping user content for markdown table cells, replacing only \`|\` with \`\\|\` triggers CodeQL's "Incomplete string escaping or encoding" alert (high severity). The fix is to escape backslashes first, then pipes: \`str.replace(/\\\\/g, "\\\\\\\\" ).replace(/\\|/g, "\\\\|")\`. In getsentry/cli this is centralized as \`escapeMarkdownCell()\` in \`src/lib/formatters/markdown.ts\`. All formatters that build markdown table rows (human.ts, log.ts, trace.ts) must use this helper instead of inline \`.replace()\` calls. + +* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: In getsentry/cli tests, \`mockFetch()\` sets \`globalThis.fetch\` to a new function. Calling it twice replaces the first mock entirely. A common bug: calling \`mockBinaryDownload()\` then \`mockGitHubVersion()\` means the binary download URL hits the version mock (returns 404). Fix: create a single unified fetch mock that handles ALL endpoints the test needs (version API, binary download, npm registry, version.json). Pattern: \`\`\`typescript mockFetch(async (url) => { const urlStr = String(url); if (urlStr.includes('releases/latest')) return versionResponse; if (urlStr.includes('version.json')) return nightlyResponse; return new Response(gzipped, { status: 200 }); // binary download }); \`\`\` This caused multiple test failures when trying to test the full upgrade→download→setup pipeline. + +* **marked-terminal@7 peer dependency requires marked < 16**: \`marked-terminal@7.3.0\` declares a peer dependency of \`marked@>=1 <16\`. Installing \`marked@17\` triggers a peer dependency warning and may cause runtime issues. Use \`marked@^15\` (e.g., \`marked@15.0.12\`) for compatibility. In Bun, peer dependency warnings don't block installation but the version mismatch can cause subtle breakage. + +* **ghcr.io blob download: curl -L breaks because auth header leaks to Azure redirect**: When downloading blobs from ghcr.io via raw HTTP, the registry returns a 307 redirect to a signed Azure Blob Storage URL (\`pkg-containers.githubusercontent.com\`). Using \`curl -L\` with the \`Authorization: Bearer \\` header fails with 404 because curl forwards the auth header to Azure, which rejects it. Fix: don't use \`-L\`. Instead, extract the redirect URL and follow it separately: \`\`\`bash REDIR=$(curl -s -w '\n%{redirect\_url}' -o /dev/null -H "Authorization: Bearer $TOKEN" "$BLOB\_URL" | tail -1) curl -sf "$REDIR" -o output.file \`\`\` This affects any tool using raw HTTP to pull from ghcr.io — the ORAS CLI handles this internally but install scripts using plain curl need the two-step approach. + +* **GHCR packages created as private by default — must manually make public once**: When first pushing to \`ghcr.io/getsentry/cli\` via ORAS or Docker, the container package is created with \`visibility: private\`. Anonymous pulls fail until the package is made public. This is a one-time manual step via GitHub UI at the package settings page (Danger Zone → Change visibility → Public). The GitHub Packages API for changing visibility requires org admin scopes that aren't available via \`gh auth\` by default. For the getsentry/cli nightly distribution, the package has already been made public. + +* **Craft gh-pages target wipes entire branch on publish**: Craft's \`GhPagesTarget.commitArchiveToBranch()\` runs \`git rm -r -f .\` before extracting the docs archive, deleting ALL existing files on the gh-pages branch. Any additional files placed there (e.g., nightly binaries in a \`nightly/\` directory) would be wiped on every stable release. The \`cli.sentry.dev\` site is served from the gh-pages branch (CNAME file confirms this). If using gh-pages for hosting non-docs files, either accept the brief outage window until the next main push re-adds them, add a \`postReleaseCommand\` in \`.craft.yml\` to restore the files, or use a different hosting mechanism entirely. + +* **upload-artifact strips directory prefix from glob paths**: When \`actions/upload-artifact@v4\` uploads with \`path: dist-bin/sentry-\*\`, it strips the \`dist-bin/\` directory prefix — the artifact stores files as \`sentry-linux-x64\`, \`sentry-linux-x64.gz\`, etc. at the root level. When \`actions/download-artifact@v4\` with \`merge-multiple: true\` and \`path: artifacts\` downloads these, files end up at \`artifacts/sentry-\*.gz\`, NOT \`artifacts/dist-bin/sentry-\*.gz\`. This caused the publish-nightly job to fail with \`no matches found for artifacts/dist-bin/\*.gz\`. The fix was changing the glob to \`artifacts/\*.gz\`. + +* **GitHub immutable releases prevent rolling nightly tag pattern**: The getsentry/cli repo has immutable releases enabled (org/repo setting, will NOT be turned off). This means: (1) once a release is published, its assets cannot be modified or deleted, (2) a tag used by a published release can NEVER be reused, even after deleting the release — \`gh release delete nightly --cleanup-tag\` followed by \`gh release create nightly\` fails with \`tag\_name was used by an immutable release\`. Draft releases ARE mutable but use unpredictable \`/download/untagged-xxx/\` URLs instead of tag-based URLs, and publishing a draft with a previously-used tag also fails. This breaks the original nightly design of a single rolling \`nightly\` tag. The \`nightly\` tag is now permanently poisoned. New approach needed: per-version release tags (e.g., \`0.13.0-dev.1772062077\`) with API-based discovery of the latest prerelease. + +* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: In getsentry/cli tests, \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` inside the repo tree. When code calls \`detectDsn(cwd)\` with this temp dir as cwd (e.g., via \`resolveOrg({ cwd })\`), \`findProjectRoot\` walks up from \`.test-tmp/prefix-xxx\` and finds the repo's \`.git\` directory, causing DSN detection to scan the actual source code for Sentry DSNs. This can trigger network calls that hit test fetch mocks (returning 404s or unexpected responses), leading to 5-second test timeouts. Fix: always use \`useTestConfigDir(prefix, { isolateProjectRoot: true })\` when the test exercises any code path that might call \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\` with the config dir as cwd. The \`isolateProjectRoot\` option creates a \`.git\` directory inside the temp dir, stopping the upward walk immediately. + +* **mock.module bleeds across Bun test files — never include test/isolated/ in test:unit**: In Bun's test runner, \`mock.module()\` pollutes the shared module registry for ALL subsequently-loaded test files in the same \`bun test\` invocation. Including \`test/isolated/\` in \`test:unit\` caused 132 failures because the \`node:child\_process\` mock leaked into DB tests, config tests, and others that transitively import child\_process. The \`test:isolated\` script MUST remain separate from \`test:unit\`. This also means isolated test coverage does NOT appear in Codecov PR patch coverage — only \`test:unit\` feeds lcov. Accept that code paths requiring \`mock.module\` (e.g., \`node:child\_process spawn\` wrappers like \`runCommand\`, \`isInstalledWith\`, \`executeUpgradeHomebrew\`, \`executeUpgradePackageManager\`) will have zero Codecov coverage. + +* **GitHub Actions: use deterministic timestamps across jobs, not Date.now()**: When multiple GitHub Actions jobs need to agree on a timestamp-based version string (e.g., nightly builds where \`build-binary\` bakes the version in and \`publish-nightly\` creates version.json), never use \`Date.now()\` or \`Math.floor(Date.now()/1000)\` independently in each job. Jobs run at different times, producing different timestamps. Instead, derive the timestamp from a shared deterministic source like \`github.event.head\_commit.timestamp\` (the commit time). Convert to Unix seconds: \`date -d '\' +%s\`. This ensures all matrix entries and downstream jobs produce the same version string. + +* **version-check.test.ts has pre-existing unmocked fetch calls**: In getsentry/cli, \`test/lib/version-check.test.ts\` makes unmocked fetch calls to \`https://api.github.com/repos/getsentry/cli/releases/latest\` and to Sentry's ingest endpoint. These are pre-existing issues that produce \`\[TEST] Unexpected fetch call\` warnings in test output but don't cause test failures. Not related to any specific PR — existed before the Homebrew changes. + +* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: The @sentry/api SDK creates a \`Request\` object with Content-Type set in its headers, then calls \`\_fetch(request2)\` with only one argument (no init). In sentry-client.ts's \`authenticatedFetch\`, \`init\` is undefined, so \`prepareHeaders(init, token)\` creates empty headers from \`new Headers(undefined)\`. When \`fetch(Request, {headers})\` is called, Node.js strictly follows the spec where init headers replace Request headers entirely — stripping Content-Type. This causes HTTP 415 'Unsupported media type' errors on POST requests (e.g., startSeerIssueFix). Bun may merge headers instead, so the bug only manifests under Node.js runtime. Fix: \`prepareHeaders\` must accept the input parameter and fall back to \`input.headers\` when \`init\` is undefined. Also fix method extraction: \`init?.method\` returns undefined for Request-only calls, defaulting to 'GET' even for POST requests. + +* **Several commands bypass telemetry by importing buildCommand from @stricli/core directly**: src/lib/command.ts wraps Stricli's buildCommand to auto-capture flag/arg telemetry via Sentry. But trace/list, trace/view, log/view, api.ts, and help.ts import buildCommand directly from @stricli/core, silently skipping telemetry. Fix: change their imports to use ../../lib/command.js. Consider adding a Biome lint rule (noRestrictedImports equivalent) to prevent future regressions. + +* **@sentry/api SDK issues filed to sentry-api-schema repo**: All @sentry/api SDK issues should be filed to https://github.com/getsentry/sentry-api-schema/ and assigned to @MathurAditya724. Two known issues: (1) unwrapResult() discards Link response headers, silently truncating listTeams/listRepositories at 100 items and preventing cursor pagination. (2) No paginated variants exist for team/repo/issue list endpoints, forcing callers to bypass the SDK with raw requests. + +* **Esbuild banner template literal double-escape for newlines**: When using esbuild's \`banner\` option with a TypeScript template literal containing string literals that need \`\n\` escape sequences: use \`\\\\\\\n\` in the TS source. The chain is: TS template literal \`\\\\\\\n\` → esbuild banner output \`\\\n\` → JS runtime interprets as newline. Using only \`\\\n\` in the TS source produces a literal newline character inside a JS double-quoted string, which is a SyntaxError. This applies to any esbuild banner/footer that injects JS strings containing escape sequences. Discovered in script/bundle.ts for the Node.js version guard error message. + +* **Craft minVersion >= 2.21.0 silently disables custom bump-version.sh**: When \`minVersion\` in \`.craft.yml\` is >= 2.21.0 and no \`preReleaseCommand\` is defined, Craft switches from running the default \`bash scripts/bump-version.sh\` to automatic version bumping based on configured publish targets. If the only target is \`github\` (which doesn't support auto-bump — only npm, pypi, crates, gem, pub-dev, hex, nuget do), the version bump silently does nothing. The release gets tagged with unbumped files. Fix: explicitly set \`preReleaseCommand: bash scripts/bump-version.sh\` in \`.craft.yml\` when using a custom bump script with targets that don't support auto-bump. This caused the 26.2.0 release to ship with \`:nightly\` image tags. + +* **Streaming formatters can't use marked-terminal incrementally — tables need all rows**: Markdown tables rendered through marked-terminal (via cli-table3) require all rows upfront to compute column widths. You cannot incrementally render one row at a time through \`renderMarkdown()\`. For streaming/polling formatters like \`formatLogRow\`, \`formatTraceRow\`, \`formatLogsHeader\` that write row-by-row, two approaches: (1) TTY mode: keep current padded text with ANSI colors for efficient incremental display, (2) non-TTY mode: emit raw markdown table row syntax (\`| col | col |\`) which is independently readable without width calculation. Individual cell \*values\* can still be styled via \`renderInlineMarkdown()\` (using \`marked.parseInline()\`) — this renders inline markdown like \`\*\*bold\*\*\`, \`\` \`code\` \`\`, and \`\*italic\*\` without block-level wrapping. This lets streaming rows benefit from markdown formatting per-cell without needing the full table pipeline. This means streaming formatters bypass \`renderMarkdown()\` by design but can use the inline variant. + +### Pattern + + +* **CLI UX: auto-correct common user mistakes with stderr warnings instead of hard errors**: When a CLI command can unambiguously detect a common user mistake (like using the wrong separator character), prefer auto-correcting the input and printing a warning to stderr over throwing a hard error. This is safe when: (1) the input is already invalid and would fail anyway, (2) there's no ambiguity in the correction, and (3) the warning goes to stderr so it doesn't interfere with JSON/stdout output. Implementation pattern: normalize inputs at the command level before passing to pure parsing functions, keeping the parsers side-effect-free. The \`gh\` CLI (GitHub CLI) is the UX model — match its conventions. + +* **esbuild metafile output bytes vs input bytes — use output for real size impact**: When analyzing bundle size with esbuild's metafile, \`result.metafile.inputs\` shows raw source file sizes BEFORE minification and tree-shaking — these are misleading for size impact analysis. A 3.3MB input file may contribute 0 bytes to output if tree-shaken. Use \`result.metafile.outputs\[outfile].inputs\` to see actual per-file output contribution after minification. To dump metafile: add \`import { writeFileSync } from 'node:fs'; writeFileSync('/tmp/meta.json', JSON.stringify(result.metafile));\` after the build call, then analyze with \`jq\`. The bundle script at script/bundle.ts generates metafile but doesn't write it to disk by default. + +* **Bun.spawn is writable — use direct assignment for test spying instead of mock.module**: Unlike \`node:child\_process\` imports (which require \`mock.module\` and isolated test files), \`Bun.spawn\` is a writable property on the global \`Bun\` object. Tests can replace it directly in \`beforeEach\`/\`afterEach\` without module-level mocking: \`\`\`typescript let originalSpawn: typeof Bun.spawn; beforeEach(() => { originalSpawn = Bun.spawn; Bun.spawn = ((cmd, \_opts) => ({ exited: Promise.resolve(0), })) as typeof Bun.spawn; }); afterEach(() => { Bun.spawn = originalSpawn; }); \`\`\` This avoids the mock.module bleed problem entirely and works in regular \`test:unit\` files (counts toward Codecov). Used successfully in \`test/commands/cli/upgrade.test.ts\` to test \`runSetupOnNewBinary\` and \`migrateToStandaloneForNightly\` which spawn child processes via \`Bun.spawn\`. + +* **ANSI codes survive marked-terminal + cli-table3 pipeline in table cells**: Pre-rendered ANSI escape codes (e.g., from chalk) embedded in markdown table cell values survive the \`marked\` → \`marked-terminal\` → \`cli-table3\` rendering pipeline. cli-table3 uses \`string-width\` which correctly treats ANSI codes as zero-width for column width calculation. This means you can pre-color individual cell values with chalk/ANSI before embedding them in a markdown table string, and the colors will render correctly in the terminal. Verified experimentally in getsentry/cli with \`marked@15\` + \`marked-terminal@7.3.0\` running under Bun. This enables per-cell semantic coloring (e.g., red for errors, green for resolved) in markdown-rendered tables without needing markdown color syntax (which doesn't exist). + +* **Formatter return type migration: string\[] to string for markdown rendering**: The formatter functions (\`formatLogDetails\`, \`formatTraceSummary\`, \`formatOrgDetails\`, \`formatProjectDetails\`, \`formatIssueDetails\`) were migrated from returning \`string\[]\` (array of lines) to returning \`string\` (rendered markdown). When updating tests for this migration: (1) remove \`.join("\n")\` calls, (2) replace \`.map(stripAnsi)\` with \`stripAnsi(result)\`, (3) replace \`Array.isArray(result)\` checks with \`typeof result === "string"\`, (4) replace line-by-line exact match tests (\`lines\[0] === "..."\`, \`lines.some(l => l.includes(...))\`) with content-based checks (\`result.includes(...)\`) since markdown tables render with Unicode box-drawing characters, not padded text columns. The \`writeTable()\` function also changed from text-padded columns to markdown table rendering. + +* **Markdown table structure for marked-terminal: blank header row + separator + data rows**: When building markdown tables for \`marked-terminal\` rendering in this codebase, the pattern is: blank header row (\`| | |\`), then separator (\`|---|---|\`), then data rows (\`| \*\*Label\*\* | value |\`). Putting data rows before the separator produces malformed tables where cell values don't render. This was discovered when the SDK section in \`log.ts\` had the data row before the separator, causing the SDK name to not appear in output. All key-value detail sections (Context, SDK, Trace, Source Location, OpenTelemetry) in \`formatLogDetails\`, \`formatOrgDetails\`, \`formatProjectDetails\`, \`formatIssueDetails\`, and \`formatTraceSummary\` use this pattern. + +* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts follow this pattern: (1) call \`getOrgSdkConfig(orgSlug)\` which resolves the org's regional URL and returns an SDK client config, (2) spread config into the SDK function call: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass result to \`unwrapResult(result, errorContext)\`. There are 14+ usages of this pattern. The \`getOrgSdkConfig\` function (line ~167) calls \`resolveOrgRegion(orgSlug)\` then \`getSdkConfig(regionUrl)\`. Follow this exact pattern when adding new org-scoped endpoints like \`getIssueInOrg\`. + +* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as required or advisory checks. Both typically take 2-3 minutes. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \ --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`. + +* **Isolated test files for mock.module in Bun tests**: In the getsentry/cli repo, tests that use Bun's \`mock.module()\` must be placed in \`test/isolated/\` as separate test files AND run via the separate \`test:isolated\` script (not \`test:unit\`). \`mock.module\` affects the entire module registry and bleeds into ALL subsequently-loaded test files in the same \`bun test\` invocation. Attempting to include \`test/isolated/\` in \`test:unit\` caused 132 test failures from \`node:child\_process\` mock pollution. Consequence: isolated test coverage does NOT appear in Codecov PR patch metrics. For code using \`Bun.spawn\` (not \`node:child\_process\`), prefer direct property assignment (\`Bun.spawn = mockFn\`) in regular test files instead — \`Bun.spawn\` is writable and doesn't require mock.module. + +* **GitHub Actions: skipped needs jobs don't block downstream**: In GitHub Actions, if a job in \`needs\` is skipped due to its \`if\` condition evaluating to false, downstream jobs that depend on it still run (skipped ≠ failed). Output values from the skipped job are empty strings. This enables a pattern where a job like \`nightly-version\` has \`if: github.ref == 'refs/heads/main'\` and downstream jobs like \`build-binary\` list it in \`needs\` — on PRs the nightly job is skipped, outputs are empty, and conditional steps using \`if: needs.nightly-version.outputs.version != ''\` are safely skipped. No need for complex conditional \`needs\` expressions. + +* **OpenCode worktree blocks checkout of main — use stash + new branch instead**: When working in the getsentry/cli repo, \`git checkout main\` fails because main is used by an OpenCode worktree at \`~/.local/share/opencode/worktree/\`. Workaround: stash changes, fetch, create a new branch from origin/main (\`git stash && git fetch && git checkout -b \ origin/main && git stash pop\`), then pop stash. Do NOT try to checkout main directly. This also applies when rebasing onto latest main — you must use \`origin/main\` as the target, never the local \`main\` branch. + +### Preference + + +* **Code style**: User prefers no backwards-compat shims, fix callers directly + +* **General coding preference**: Prefer explicit error handling over silent failures + +* **General coding preference**: Prefer explicit error handling over silent failures + +* **Code style**: User prefers no backwards-compat shims, fix callers directly + +* **.opencode directory should be gitignored**: The \`.opencode/\` directory (used by OpenCode for plans, session data, etc.) should be in \`.gitignore\` and never committed. It was added to \`.gitignore\` alongside \`.idea\` and \`.DS\_Store\` in the editor/tool ignore section. + +* **CI: define shared env vars at workflow level, not per-job**: Reviewer (BYK) prefers defining environment variables that are used by multiple jobs at the workflow-level \`env:\` block rather than repeating them in each job's step-level \`env:\`. GitHub Actions workflow-level \`env\` is inherited by all jobs and steps. Example: \`COMMIT\_TIMESTAMP\` was moved from being defined in both \`build-binary\` and \`publish-nightly\` to a single workflow-level declaration. + +* **CI scripts: prefer jq/sed over node -e for JSON manipulation**: Reviewer (BYK) prefers using standard Unix tools (\`jq\`, \`sed\`, \`awk\`) over \`node -e\` for simple JSON manipulation in CI workflow scripts. For example, reading/modifying package.json version: \`jq -r .version package.json\` to read, \`jq --arg v "$NEW" '.version = $v' package.json > tmp && mv tmp package.json\` to write. This avoids requiring Node.js to be installed in CI steps that only need basic JSON operations, and is more readable for shell-centric workflows. + diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index fb1f1a92..70c9770a 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -113,14 +113,6 @@ function writeListHeader(stdout: Writer, title: string): void { stdout.write(`${title}:\n\n`); } -/** Issue with formatting options attached */ -/** @internal */ export type IssueWithOptions = { - issue: SentryIssue; - /** Org slug — used as part of the per-project key in trimWithProjectGuarantee. */ - orgSlug: string; - formatOptions: FormatShortIdOptions; -}; - /** * Write footer with usage tip. * @@ -570,9 +562,9 @@ async function fetchWithBudget( * @returns Trimmed array in the same sorted order */ function trimWithProjectGuarantee( - issues: IssueWithOptions[], + issues: IssueTableRow[], limit: number -): IssueWithOptions[] { +): IssueTableRow[] { if (issues.length <= limit) { return issues; } diff --git a/src/lib/formatters/colors.ts b/src/lib/formatters/colors.ts index d542ee48..a5067c91 100644 --- a/src/lib/formatters/colors.ts +++ b/src/lib/formatters/colors.ts @@ -52,9 +52,10 @@ export const boldUnderline = (text: string): string => * @returns Text wrapped in OSC 8 hyperlink escape sequences */ export function terminalLink(text: string, url: string): string { - // OSC 8 ; params ; URI ST text OSC 8 ; ; ST - // Using BEL () as string terminator for broad compatibility - return `]8;;${url}${text}]8;;`; + // OSC 8 ; params ; URI BEL text OSC 8 ; ; BEL + // \x1b] opens the OSC sequence; \x07 (BEL) terminates it. + // Using BEL instead of ST (\x1b\\) for broad terminal compatibility. + return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`; } // Semantic Helpers diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index b1406132..4942e25b 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -396,6 +396,8 @@ function computeAliasShorthand(shortId: string, projectAlias?: string): string { /** Row data prepared for the issue table */ export type IssueTableRow = { issue: SentryIssue; + /** Org slug — used as project key in trimWithProjectGuarantee and similar utilities. */ + orgSlug: string; formatOptions: FormatShortIdOptions; }; @@ -472,7 +474,9 @@ export function writeIssueTable( }, { header: "TITLE", - value: ({ issue }) => issue.title, + // Escape markdown emphasis chars so underscores/asterisks in issue titles + // (e.g. "Expected got ") don't render as italic/bold text. + value: ({ issue }) => escapeMarkdownInline(issue.title), } ); diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 18341500..8e90a20a 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -11,6 +11,7 @@ import { divider, escapeMarkdownCell, escapeMarkdownInline, + isPlainOutput, mdKvTable, mdRow, mdTableHeader, @@ -18,7 +19,7 @@ import { } from "./markdown.js"; /** Markdown color tag names for log severity levels */ -const SEVERITY_TAGS: Record = { +const SEVERITY_TAGS: Record[0]> = { fatal: "red", error: "red", warning: "yellow", @@ -42,7 +43,7 @@ function formatSeverity(severity: string | null | undefined): string { const level = (severity ?? "info").toLowerCase(); const tag = SEVERITY_TAGS[level]; const label = level.toUpperCase().padEnd(7); - return tag ? colorTag(tag as Parameters[0], label) : label; + return tag ? colorTag(tag, label) : label; } /** @@ -87,12 +88,16 @@ export function formatLogRow(log: SentryLog): string { /** * Format column header for logs list (used in streaming/follow mode). * - * In plain mode: emits a markdown table header + separator row. + * In plain mode: emits a proper markdown table header + separator row so that + * the streamed rows compose into a valid CommonMark document when redirected. * In rendered mode: emits an ANSI-muted text header with a rule separator. * * @returns Header string (includes trailing newline) */ export function formatLogsHeader(): string { + if (isPlainOutput()) { + return `${mdTableHeader(LOG_TABLE_COLS)}\n`; + } return `${mdRow(LOG_TABLE_COLS.map((c) => `**${c}**`))}${divider(80)}\n`; } @@ -108,7 +113,10 @@ export function formatLogTable(logs: SentryLog[]): string { const rows = logs .map((log) => { const timestamp = formatTimestamp(log.timestamp); - const severity = formatSeverity(log.severity).trim(); + // formatSeverity wraps the padEnd label inside a color tag, so .trim() + // on the result would be a no-op. Use formatSeverityLabel (no padding) + // for the batch table which handles its own column sizing. + const severity = formatSeverityLabel(log.severity); const message = escapeMarkdownCell(log.message ?? ""); const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; return `| ${timestamp} | ${severity} | ${message}${trace} |`; @@ -128,7 +136,7 @@ function formatSeverityLabel(severity: string | null | undefined): string { const level = (severity ?? "info").toLowerCase(); const tag = SEVERITY_TAGS[level]; const label = level.toUpperCase(); - return tag ? colorTag(tag as Parameters[0], label) : label; + return tag ? colorTag(tag, label) : label; } /** diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 5be6f9c4..ad039d0f 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -77,16 +77,27 @@ export function isPlainOutput(): boolean { /** * Escape a string for safe use inside a markdown table cell. * - * Collapses newlines, escapes backslashes, then pipes. + * Collapses newlines, escapes backslashes, pipes, and angle brackets. + * Angle brackets must be escaped to `<`/`>` so that user-supplied + * content (e.g. `Expected `) is not parsed as an HTML tag by + * `marked`, which would silently drop the content via `renderHtmlToken`. */ export function escapeMarkdownCell(value: string): string { - return value.replace(/\n/g, " ").replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); + return value + .replace(/\n/g, " ") + .replace(/\\/g, "\\\\") + .replace(/\|/g, "\\|") + .replace(//g, ">"); } /** * Escape CommonMark inline emphasis characters. * - * Prevents `_`, `*`, `` ` ``, `[`, `]` from being consumed by the parser. + * Prevents `_`, `*`, `` ` ``, `[`, `]`, `<`, and `>` from being consumed + * by the parser. Angle brackets are HTML-escaped so that user-supplied + * content (e.g. `Expected got `) is not silently dropped + * when `marked` parses the text as HTML tokens. */ export function escapeMarkdownInline(value: string): string { return value @@ -95,7 +106,9 @@ export function escapeMarkdownInline(value: string): string { .replace(/_/g, "\\_") .replace(/`/g, "\\`") .replace(/\[/g, "\\[") - .replace(/\]/g, "\\]"); + .replace(/\]/g, "\\]") + .replace(//g, ">"); } /** @@ -153,8 +166,11 @@ export function mdKvTable( lines.push("| | |"); lines.push("|---|---|"); for (const [label, value] of rows) { + // Escape backslashes first, then replace pipes with a Unicode box character + // so that backslash-pipe sequences in values don't produce escaped pipes in + // the rendered output. Newlines are collapsed to spaces. lines.push( - `| **${label}** | ${value.replace(/\n/g, " ").replace(/\|/g, "\u2502")} |` + `| **${label}** | ${value.replace(/\n/g, " ").replace(/\\/g, "\\\\").replace(/\|/g, "\u2502")} |` ); } return lines.join("\n"); @@ -219,6 +235,27 @@ const RE_OPEN_TAG = /^<([a-z]+)>$/i; const RE_CLOSE_TAG = /^<\/([a-z]+)>$/i; const RE_SELF_TAG = /^<([a-z]+)>([\s\S]*?)<\/\1>$/i; +/** + * Strip color tags (``, ``, etc.) from a string, + * leaving only the inner text. + * + * Used in plain output mode so that `colorTag()` values don't leak as literal + * HTML-like tags into piped / CI / redirected output. + */ +function stripColorTags(text: string): string { + // Repeatedly replace all supported color tag pairs until none remain. + // The loop handles nested tags (uncommon but possible). + const tagPattern = Object.keys(COLOR_TAGS).join("|"); + const re = new RegExp(`<(${tagPattern})>([\\s\\S]*?)<\\/\\1>`, "gi"); + let result = text; + let prev: string; + do { + prev = result; + result = result.replace(re, "$2"); + } while (result !== prev); + return result; +} + /** * Render an inline HTML token as a color-tagged string. * @@ -385,11 +422,12 @@ function renderBlocks(tokens: Token[]): string { case "heading": { const t = token as Tokens.Heading; const text = renderInline(t.tokens); - if (t.depth <= 2) { - parts.push(chalk.hex(COLORS.cyan).bold(text)); - } else { - parts.push(chalk.hex(COLORS.cyan).bold(text)); - } + // h1/h2 → bold cyan; h3+ → plain cyan (less prominent) + const styled = + t.depth <= 2 + ? chalk.hex(COLORS.cyan).bold(text) + : chalk.hex(COLORS.cyan)(text); + parts.push(styled); parts.push(""); break; } @@ -524,7 +562,9 @@ function renderTableToken(table: Tokens.Table): string { */ export function renderMarkdown(md: string): string { if (isPlainOutput()) { - return md.trimEnd(); + // Strip color tags so text doesn't leak as literal markup in + // piped / CI / redirected output (documented "plain mode" contract). + return stripColorTags(md).trimEnd(); } const tokens = marked.lexer(md); return renderBlocks(tokens).trimEnd(); @@ -539,7 +579,8 @@ export function renderMarkdown(md: string): string { */ export function renderInlineMarkdown(md: string): string { if (isPlainOutput()) { - return md; + // Strip color tags for the same reason as renderMarkdown. + return stripColorTags(md); } const tokens = marked.lexer(md); return renderInline(tokens.flatMap(flattenInline)); diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index c70ce4ac..b638b050 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -9,6 +9,7 @@ import { formatRelativeTime } from "./human.js"; import { divider, escapeMarkdownCell, + isPlainOutput, mdRow, mdTableHeader, renderMarkdown, @@ -86,9 +87,16 @@ export function formatTraceRow(item: TransactionListItem): string { /** * Format column header for traces list (streaming mode). * + * In plain mode: emits a proper markdown table header + separator row so + * that streamed rows compose into a valid CommonMark document when redirected. + * In rendered mode: emits an ANSI-muted text header with a rule separator. + * * @returns Header string (includes trailing newline) */ export function formatTracesHeader(): string { + if (isPlainOutput()) { + return `${mdTableHeader(TRACE_TABLE_COLS)}\n`; + } const names = TRACE_TABLE_COLS.map((c) => c.endsWith(":") ? c.slice(0, -1) : c ); diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index 9cb68b78..67fa6c5b 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -225,7 +225,9 @@ describe("formatLogsHeader (plain mode)", () => { test("emits markdown table header and separator", () => { const result = formatLogsHeader(); - expect(result).toContain("| **Timestamp** | **Level** | **Message** |"); + // Plain mode produces mdTableHeader output (no bold markup), followed by separator + expect(result).toContain("| Timestamp | Level | Message |"); + expect(result).toContain("| --- | --- | --- |"); }); test("ends with newline", () => { diff --git a/test/lib/formatters/markdown.test.ts b/test/lib/formatters/markdown.test.ts index 97121e17..888512c2 100644 --- a/test/lib/formatters/markdown.test.ts +++ b/test/lib/formatters/markdown.test.ts @@ -443,7 +443,10 @@ describe("colorTag", () => { test("plain mode: tags are stripped leaving bare text", () => { withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, true, () => { const result = renderInlineMarkdown(colorTag("red", "ERROR")); - expect(result).toContain("ERROR"); + // Color tags must be stripped in plain mode — they must not leak as literal markup + expect(result).not.toContain(""); + expect(result).not.toContain(""); + expect(result).toContain("ERROR"); }); }); }); diff --git a/test/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index 5a252ba4..dcfcc6a1 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -189,9 +189,10 @@ describe("formatTracesHeader (plain mode)", () => { test("emits markdown table header and separator", () => { const result = formatTracesHeader(); - expect(result).toContain( - "| **Trace ID** | **Transaction** | **Duration** | **When** |" - ); + // Plain mode produces mdTableHeader output (no bold markup), with separator + expect(result).toContain("| Trace ID | Transaction | Duration | When |"); + // Duration column is right-aligned (`:` suffix in TRACE_TABLE_COLS) + expect(result).toContain("| --- | --- | ---: | --- |"); }); test("ends with newline", () => { From a0830229803b760f958ee1d2520c8772ff2f71a0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 01:26:27 +0000 Subject: [PATCH 38/52] fix: deduplicate AGENTS.md lore sections and COLORS constant - Remove duplicate Long-term Knowledge section in AGENTS.md (131 lines of redundant lore entries, including 2 duplicate preference entries with different IDs) - Export COLORS from colors.ts and import in markdown.ts instead of maintaining a separate copy --- AGENTS.md | 131 --------------------------------- src/lib/formatters/colors.ts | 2 +- src/lib/formatters/markdown.ts | 13 +--- 3 files changed, 2 insertions(+), 144 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e2fa466d..fb92cb28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -709,134 +709,3 @@ mock.module("./some-module", () => ({ * **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. - - -## Long-term Knowledge - -### Architecture - - -* **@sentry/node-core pulls in @apm-js-collab/code-transformer (3.3MB) but it's tree-shaken**: The dependency chain \`@sentry/node\` → \`@sentry/node-core\` → \`@apm-js-collab/tracing-hooks\` → \`@apm-js-collab/code-transformer\` adds a 3.3MB input file to the esbuild bundle analysis. However, esbuild fully tree-shakes it out — it contributes 0 bytes to the final npm bundle output. This was verified by checking both the released 0.13.0 bundle and the current build: neither contains any \`code-transformer\` or \`apm-js-collab\` strings. Don't be alarmed by its presence in metafile inputs. - -* **GitHub Packages has no generic/raw file registry — only typed registries**: GitHub Packages only supports typed registries (Container/npm/Maven/NuGet/RubyGems) — there is no generic file store where you can upload a binary and get a download URL. For distributing arbitrary binaries: the Container registry (ghcr.io) via ORAS works but requires a 3-step download (token → manifest → blob). The npm registry at \`npm.pkg.github.com\` requires authentication even for public packages, making it unsuitable for install scripts. This means for binary distribution on GitHub without GitHub Releases, GHCR+ORAS is the only viable GitHub Packages option. - -* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: The \`cli.sentry.dev\` domain points to GitHub Pages for the getsentry/cli repo, confirmed by the CNAME file on the gh-pages branch containing \`cli.sentry.dev\`. The branch contains the Astro/Starlight docs site output plus the install script at \`/install\`. Craft's gh-pages target manages this branch, wiping and replacing all content on each stable release. The docs are built as a zip artifact named \`gh-pages.zip\` (matched by Craft's \`DEFAULT\_DEPLOY\_ARCHIVE\_REGEX: /^(?:.+-)?gh-pages\\.zip$/\`). - -* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The Sentry CLI npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses Node.js 22's built-in \`node:sqlite\` module. The \`require('node:sqlite')\` happens during module loading (via esbuild's inject option) — before any user code runs. package.json declares \`engines: { node: '>=22' }\` but pnpm/npm don't enforce this by default. A runtime version guard in the esbuild banner catches this early with a clear error message pointing users to either upgrade Node.js or use the standalone binary (\`curl -fsSL https://cli.sentry.dev/install | bash\`). - -* **parseSentryUrl does not handle subdomain-style SaaS URLs**: The URL parser in src/lib/sentry-url-parser.ts handles both path-based (\`/organizations/{org}/...\`) and subdomain-style (\`https://{org}.sentry.io/issues/123/\`) SaaS URLs. The \`matchSubdomainOrg()\` function extracts the org from the hostname when it ends with \`.sentry.io\`, supports \`/issues/{id}/\`, \`/issues/{id}/events/{eventId}/\`, and \`/traces/{traceId}/\` paths. Region subdomains (\`us\`, \`de\`) are filtered out by requiring org slugs to be longer than 2 characters. Confirmed against getsentry/sentry codebase: the subdomain IS the org slug directly (e.g., \`my-org.sentry.io\`), NOT a fixed prefix like \`o.sentry.io\`. The Sentry backend builds permalinks via \`organization.absolute\_url()\` → \`generate\_organization\_url(slug)\` using the \`system.organization-base-hostname\` template \`{slug}.sentry.io\` (src/sentry/organizations/absolute\_url.py:72-92). When customer domains are enabled (production SaaS), \`customer\_domain\_path()\` strips \`/organizations/{slug}/\` from paths. Region subdomains are filtered by Sentry's \`subdomain\_is\_region()\`, aligning with the \`org.length <= 2\` check. Self-hosted uses path-based: \`/organizations/{org\_slug}/issues/{id}/\`. - -* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution now uses a multi-step approach in \`resolveNumericIssue()\` (extracted from \`resolveIssue\` to reduce cognitive complexity). Resolution order: (1) \`resolveOrg({ cwd })\` tries DSN/env/config for org context, (2) if org found, uses \`getIssueInOrg(org, id)\` with region routing, (3) if no org, falls back to unscoped \`getIssue(id)\`, (4) extracts org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. The \`explicit-org-numeric\` case now uses \`getIssueInOrg(parsed.org, id)\` instead of the unscoped endpoint. \`getIssueInOrg\` was added to api-client.ts using the SDK's \`retrieveAnIssue\` with the standard \`getOrgSdkConfig + unwrapResult\` pattern. The \`resolveOrgAndIssueId\` wrapper (used by \`explain\`/\`plan\`) no longer throws "Organization is required" for bare numeric IDs when the permalink contains the org slug. - -* **Install script nightly channel support**: The curl install script (\`cli.sentry.dev/install\`) supports \`--channel nightly\`. For nightly: downloads the binary directly from the \`nightly\` release tag (no version.json fetch needed — just uses \`nightly\` as both download tag and display string). Passes \`--channel nightly\` to \`$binary cli setup --install --method curl --channel nightly\` so the channel is persisted in DB. Usage: \`curl -fsSL https://cli.sentry.dev/install | bash -s -- --channel nightly\`. The install script does NOT fetch version.json — that's only used by the upgrade/version-check flow to compare versions. - -### Decision - - -* **Nightly release: delete-then-upload instead of clobber for asset management**: The publish-nightly CI job deletes ALL existing release assets before uploading new ones, rather than using \`gh release upload --clobber\`. This ensures that if asset names change (e.g., removing a platform or renaming files), stale assets don't linger on the nightly release indefinitely. Pattern: \`gh release view nightly --json assets --jq '.assets\[].name' | while read -r name; do gh release delete-asset nightly "$name" --yes; done\` followed by \`gh release upload nightly \\` (no --clobber needed since assets were cleared). - -* **Raw markdown output for non-interactive terminals, rendered for TTY**: Design decision for getsentry/cli: output raw CommonMark markdown when stdout is not interactive (piped, redirected, CI), and only render through marked-terminal when a human is looking at it in a TTY. Detection: \`const isInteractive = process.stdout.isTTY\` — no \`process.env.CI\` check needed, since if a CI runner allocates a pseudo-TTY it can render the styled output fine. Override env vars with precedence: \`SENTRY\_PLAIN\_OUTPUT\` (most specific) > \`NO\_COLOR\` > auto-detect via \`isTTY\`. \`SENTRY\_PLAIN\_OUTPUT=1\` forces raw markdown even on TTY; \`SENTRY\_PLAIN\_OUTPUT=0\` forces rendered even when piped. \`NO\_COLOR\` (no-color.org standard) also triggers plain mode since rendered output is ANSI-colored. Chalk auto-disables colors when piped, so pre-embedded ANSI codes become plain text in raw mode. Output modes: \`--json\` → JSON (unchanged), TTY → rendered markdown via marked-terminal, non-TTY → raw CommonMark. For streaming formatters (log/trace row-by-row output), TTY keeps current ANSI-colored padded text for efficient incremental display, while non-TTY emits markdown table rows with header+separator on first write, producing valid markdown files when redirected. - -### Gotcha - - -* **git notes are lost on commit amend — must re-attach to new SHA**: Git notes are attached to a specific commit SHA. When you \`git commit --amend\`, the old commit is replaced with a new one (different SHA), and the note attached to the old SHA becomes orphaned. After amending, you must re-add the note to the new commit with \`git notes add\` targeting the new SHA. This also affects \`git push --force\` of notes refs — the remote note ref still points to the old SHA. - -* **pnpm overrides with version-range keys don't force upgrades of already-compatible resolutions**: pnpm overrides with version-range selectors like \`"minimatch@>=10.0.0 <10.2.1": ">=10.2.1"\` do NOT work as expected for forcing upgrades of transitive deps that already satisfy their parent's semver range. If a parent requests \`^10.1.1\` and pnpm resolves \`10.1.1\`, the override key \`>=10.0.0 <10.2.1\` should match but doesn't reliably force re-resolution — even with \`pnpm install --force\`. The workaround is a blanket override without a version selector: \`"minimatch": ">=10.2.1"\`. This is only safe when ALL consumers are on the same major version line (otherwise it's a breaking change). Verify first with \`pnpm why \\` that no other major versions exist in the tree before using a blanket override. - -* **pnpm overrides with >= can cross major versions — use ^ to constrain**: When using pnpm overrides to patch a transitive dependency vulnerability, \`"ajv@<6.14.0": ">=6.14.0"\` will resolve to the latest ajv (v8.x), not the latest 6.x. ajv v6 and v8 have incompatible APIs — this broke eslint (\`@eslint/eslintrc\` calls \`ajv\` v6 API, crashes with \`Cannot set properties of undefined (setting 'defaultMeta')\` on v8). Fix: use \`"ajv@<6.14.0": "^6.14.0"\` to constrain within the same major. This applies to any override where the target package has multiple major versions in the registry — always use \`^\` (or \`~\`) instead of \`>=\` to stay within the compatible major line. - -* **marked-terminal unconditionally imports cli-highlight and node-emoji — no tree-shaking possible**: marked-terminal has static top-level imports of \`cli-highlight\` (which pulls in highlight.js, ~570KB minified output) and \`node-emoji\` (which pulls in emojilib, ~208KB minified output) at lines 5-6 of its index.js. These are unconditional — there's no config option to disable them, and the \`emoji: false\` option only skips the emoji replacement function but doesn't prevent the import. esbuild cannot tree-shake static imports. This means any bundle including marked-terminal will grow by ~970KB (highlight.js + emojilib + parse5). To avoid this in a CLI bundle, you'd need to either: (1) write a custom marked renderer using only chalk, (2) fork marked-terminal with dynamic imports, or (3) use esbuild's \`external\` option (but then those packages must be available at runtime). - -* **pnpm-lock.yaml merge conflicts: regenerate don't manually merge**: When \`pnpm-lock.yaml\` has merge conflicts, never try to manually resolve the conflict markers. Instead: (1) \`git checkout --theirs pnpm-lock.yaml\` (or \`--ours\` depending on which package.json changes you want as base), (2) run \`pnpm install\` to regenerate the lockfile incorporating both sides' \`package.json\` changes (including overrides). This produces a clean lockfile that reflects the merged dependency state. Manual conflict resolution in lockfiles is error-prone and unnecessary since pnpm can regenerate it deterministically. - -* **git stash pop after merge can cause second conflict on same file**: When you stash local changes, merge, then \`git stash pop\`, the stash apply can create a NEW conflict on the same file that was just conflict-resolved in the merge. This happens because the stash was based on the pre-merge state and conflicts with the post-merge-resolution content. The resolution requires a second manual conflict resolution pass. To avoid: if stashed changes are lore/auto-generated content, consider just dropping the stash and re-running the generation tool after merge instead of popping. - -* **pnpm overrides become stale when dependency tree changes — audit with pnpm why**: pnpm overrides can become orphaned when the dependency tree changes. For example, removing a package that was the sole consumer of a transitive dep (like removing \`@sentry/typescript\` which pulled in \`tslint\` which was the only consumer of \`diff\`), or upgrading a package that switches to a differently-named dependency (like \`minimatch@10.2.4\` switching from \`@isaacs/brace-expansion\` to the unscoped \`brace-expansion\`). Orphaned overrides sit silently in package.json and could unexpectedly constrain versions if a future dependency reintroduces the package name. After removing packages or upgrading dependencies that change the transitive tree, audit overrides with \`pnpm why \\` to verify each override still has consumers in the resolved tree. Remove any that return empty results. - -* **ESM modules prevent vi.spyOn of child\_process.spawnSync — use test subclass pattern**: In Vitest with ESM, you cannot spy on exports from Node built-in modules like \`child\_process.spawnSync\` — it throws \`Cannot spy on export. Module namespace is not configurable in ESM\`. The workaround for testing code that calls \`spawnSync\` (like npm version checks) is to create a test subclass that overrides the method calling \`spawnSync\` and injects controllable values. Example: \`TestNpmTarget\` overrides \`checkRequirements()\` to set \`this.npmVersion\` from a static \`mockVersion\` field instead of calling \`spawnSync(npm, \['--version'])\`. This avoids the ESM limitation while keeping test isolation. \`vi.mock\` at module level is another option but affects all tests in the file. - -* **npm OIDC only works for publish — npm info/view still needs traditional auth**: Per npm docs: "OIDC authentication is currently limited to the publish operation. Other npm commands such as install, view, or access still require traditional authentication methods." This means \`npm info \ version\` (used by Craft's \`getLatestVersion()\`) cannot use OIDC tokens. For public packages this is fine — \`npm info\` works without auth. For private packages, a read-only \`NPM\_TOKEN\` is still needed alongside OIDC. Craft handles this by: using token auth for \`getLatestVersion()\` when a token is available, running unauthenticated otherwise, and warning + skipping version checks for private packages in OIDC mode without a token. - -* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` - -* **marked and marked-terminal must be devDependencies in bundled CLI projects**: In the getsentry/cli project, all npm packages used at runtime must be bundled by the build step (esbuild). Packages like \`marked\` and \`marked-terminal\` belong in \`devDependencies\`, not \`dependencies\`. The CI \`check:deps\` step enforces this — anything in \`dependencies\` that isn't a true runtime requirement (native addon, bin entry) will fail the check. This applies to all packages consumed only through the bundle. - -* **CodeQL flags incomplete markdown cell escaping — must escape backslash before pipe**: When escaping user content for markdown table cells, replacing only \`|\` with \`\\|\` triggers CodeQL's "Incomplete string escaping or encoding" alert (high severity). The fix is to escape backslashes first, then pipes: \`str.replace(/\\\\/g, "\\\\\\\\" ).replace(/\\|/g, "\\\\|")\`. In getsentry/cli this is centralized as \`escapeMarkdownCell()\` in \`src/lib/formatters/markdown.ts\`. All formatters that build markdown table rows (human.ts, log.ts, trace.ts) must use this helper instead of inline \`.replace()\` calls. - -* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: In getsentry/cli tests, \`mockFetch()\` sets \`globalThis.fetch\` to a new function. Calling it twice replaces the first mock entirely. A common bug: calling \`mockBinaryDownload()\` then \`mockGitHubVersion()\` means the binary download URL hits the version mock (returns 404). Fix: create a single unified fetch mock that handles ALL endpoints the test needs (version API, binary download, npm registry, version.json). Pattern: \`\`\`typescript mockFetch(async (url) => { const urlStr = String(url); if (urlStr.includes('releases/latest')) return versionResponse; if (urlStr.includes('version.json')) return nightlyResponse; return new Response(gzipped, { status: 200 }); // binary download }); \`\`\` This caused multiple test failures when trying to test the full upgrade→download→setup pipeline. - -* **marked-terminal@7 peer dependency requires marked < 16**: \`marked-terminal@7.3.0\` declares a peer dependency of \`marked@>=1 <16\`. Installing \`marked@17\` triggers a peer dependency warning and may cause runtime issues. Use \`marked@^15\` (e.g., \`marked@15.0.12\`) for compatibility. In Bun, peer dependency warnings don't block installation but the version mismatch can cause subtle breakage. - -* **ghcr.io blob download: curl -L breaks because auth header leaks to Azure redirect**: When downloading blobs from ghcr.io via raw HTTP, the registry returns a 307 redirect to a signed Azure Blob Storage URL (\`pkg-containers.githubusercontent.com\`). Using \`curl -L\` with the \`Authorization: Bearer \\` header fails with 404 because curl forwards the auth header to Azure, which rejects it. Fix: don't use \`-L\`. Instead, extract the redirect URL and follow it separately: \`\`\`bash REDIR=$(curl -s -w '\n%{redirect\_url}' -o /dev/null -H "Authorization: Bearer $TOKEN" "$BLOB\_URL" | tail -1) curl -sf "$REDIR" -o output.file \`\`\` This affects any tool using raw HTTP to pull from ghcr.io — the ORAS CLI handles this internally but install scripts using plain curl need the two-step approach. - -* **GHCR packages created as private by default — must manually make public once**: When first pushing to \`ghcr.io/getsentry/cli\` via ORAS or Docker, the container package is created with \`visibility: private\`. Anonymous pulls fail until the package is made public. This is a one-time manual step via GitHub UI at the package settings page (Danger Zone → Change visibility → Public). The GitHub Packages API for changing visibility requires org admin scopes that aren't available via \`gh auth\` by default. For the getsentry/cli nightly distribution, the package has already been made public. - -* **Craft gh-pages target wipes entire branch on publish**: Craft's \`GhPagesTarget.commitArchiveToBranch()\` runs \`git rm -r -f .\` before extracting the docs archive, deleting ALL existing files on the gh-pages branch. Any additional files placed there (e.g., nightly binaries in a \`nightly/\` directory) would be wiped on every stable release. The \`cli.sentry.dev\` site is served from the gh-pages branch (CNAME file confirms this). If using gh-pages for hosting non-docs files, either accept the brief outage window until the next main push re-adds them, add a \`postReleaseCommand\` in \`.craft.yml\` to restore the files, or use a different hosting mechanism entirely. - -* **upload-artifact strips directory prefix from glob paths**: When \`actions/upload-artifact@v4\` uploads with \`path: dist-bin/sentry-\*\`, it strips the \`dist-bin/\` directory prefix — the artifact stores files as \`sentry-linux-x64\`, \`sentry-linux-x64.gz\`, etc. at the root level. When \`actions/download-artifact@v4\` with \`merge-multiple: true\` and \`path: artifacts\` downloads these, files end up at \`artifacts/sentry-\*.gz\`, NOT \`artifacts/dist-bin/sentry-\*.gz\`. This caused the publish-nightly job to fail with \`no matches found for artifacts/dist-bin/\*.gz\`. The fix was changing the glob to \`artifacts/\*.gz\`. - -* **GitHub immutable releases prevent rolling nightly tag pattern**: The getsentry/cli repo has immutable releases enabled (org/repo setting, will NOT be turned off). This means: (1) once a release is published, its assets cannot be modified or deleted, (2) a tag used by a published release can NEVER be reused, even after deleting the release — \`gh release delete nightly --cleanup-tag\` followed by \`gh release create nightly\` fails with \`tag\_name was used by an immutable release\`. Draft releases ARE mutable but use unpredictable \`/download/untagged-xxx/\` URLs instead of tag-based URLs, and publishing a draft with a previously-used tag also fails. This breaks the original nightly design of a single rolling \`nightly\` tag. The \`nightly\` tag is now permanently poisoned. New approach needed: per-version release tags (e.g., \`0.13.0-dev.1772062077\`) with API-based discovery of the latest prerelease. - -* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: In getsentry/cli tests, \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` inside the repo tree. When code calls \`detectDsn(cwd)\` with this temp dir as cwd (e.g., via \`resolveOrg({ cwd })\`), \`findProjectRoot\` walks up from \`.test-tmp/prefix-xxx\` and finds the repo's \`.git\` directory, causing DSN detection to scan the actual source code for Sentry DSNs. This can trigger network calls that hit test fetch mocks (returning 404s or unexpected responses), leading to 5-second test timeouts. Fix: always use \`useTestConfigDir(prefix, { isolateProjectRoot: true })\` when the test exercises any code path that might call \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\` with the config dir as cwd. The \`isolateProjectRoot\` option creates a \`.git\` directory inside the temp dir, stopping the upward walk immediately. - -* **mock.module bleeds across Bun test files — never include test/isolated/ in test:unit**: In Bun's test runner, \`mock.module()\` pollutes the shared module registry for ALL subsequently-loaded test files in the same \`bun test\` invocation. Including \`test/isolated/\` in \`test:unit\` caused 132 failures because the \`node:child\_process\` mock leaked into DB tests, config tests, and others that transitively import child\_process. The \`test:isolated\` script MUST remain separate from \`test:unit\`. This also means isolated test coverage does NOT appear in Codecov PR patch coverage — only \`test:unit\` feeds lcov. Accept that code paths requiring \`mock.module\` (e.g., \`node:child\_process spawn\` wrappers like \`runCommand\`, \`isInstalledWith\`, \`executeUpgradeHomebrew\`, \`executeUpgradePackageManager\`) will have zero Codecov coverage. - -* **GitHub Actions: use deterministic timestamps across jobs, not Date.now()**: When multiple GitHub Actions jobs need to agree on a timestamp-based version string (e.g., nightly builds where \`build-binary\` bakes the version in and \`publish-nightly\` creates version.json), never use \`Date.now()\` or \`Math.floor(Date.now()/1000)\` independently in each job. Jobs run at different times, producing different timestamps. Instead, derive the timestamp from a shared deterministic source like \`github.event.head\_commit.timestamp\` (the commit time). Convert to Unix seconds: \`date -d '\' +%s\`. This ensures all matrix entries and downstream jobs produce the same version string. - -* **version-check.test.ts has pre-existing unmocked fetch calls**: In getsentry/cli, \`test/lib/version-check.test.ts\` makes unmocked fetch calls to \`https://api.github.com/repos/getsentry/cli/releases/latest\` and to Sentry's ingest endpoint. These are pre-existing issues that produce \`\[TEST] Unexpected fetch call\` warnings in test output but don't cause test failures. Not related to any specific PR — existed before the Homebrew changes. - -* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: The @sentry/api SDK creates a \`Request\` object with Content-Type set in its headers, then calls \`\_fetch(request2)\` with only one argument (no init). In sentry-client.ts's \`authenticatedFetch\`, \`init\` is undefined, so \`prepareHeaders(init, token)\` creates empty headers from \`new Headers(undefined)\`. When \`fetch(Request, {headers})\` is called, Node.js strictly follows the spec where init headers replace Request headers entirely — stripping Content-Type. This causes HTTP 415 'Unsupported media type' errors on POST requests (e.g., startSeerIssueFix). Bun may merge headers instead, so the bug only manifests under Node.js runtime. Fix: \`prepareHeaders\` must accept the input parameter and fall back to \`input.headers\` when \`init\` is undefined. Also fix method extraction: \`init?.method\` returns undefined for Request-only calls, defaulting to 'GET' even for POST requests. - -* **Several commands bypass telemetry by importing buildCommand from @stricli/core directly**: src/lib/command.ts wraps Stricli's buildCommand to auto-capture flag/arg telemetry via Sentry. But trace/list, trace/view, log/view, api.ts, and help.ts import buildCommand directly from @stricli/core, silently skipping telemetry. Fix: change their imports to use ../../lib/command.js. Consider adding a Biome lint rule (noRestrictedImports equivalent) to prevent future regressions. - -* **@sentry/api SDK issues filed to sentry-api-schema repo**: All @sentry/api SDK issues should be filed to https://github.com/getsentry/sentry-api-schema/ and assigned to @MathurAditya724. Two known issues: (1) unwrapResult() discards Link response headers, silently truncating listTeams/listRepositories at 100 items and preventing cursor pagination. (2) No paginated variants exist for team/repo/issue list endpoints, forcing callers to bypass the SDK with raw requests. - -* **Esbuild banner template literal double-escape for newlines**: When using esbuild's \`banner\` option with a TypeScript template literal containing string literals that need \`\n\` escape sequences: use \`\\\\\\\n\` in the TS source. The chain is: TS template literal \`\\\\\\\n\` → esbuild banner output \`\\\n\` → JS runtime interprets as newline. Using only \`\\\n\` in the TS source produces a literal newline character inside a JS double-quoted string, which is a SyntaxError. This applies to any esbuild banner/footer that injects JS strings containing escape sequences. Discovered in script/bundle.ts for the Node.js version guard error message. - -* **Craft minVersion >= 2.21.0 silently disables custom bump-version.sh**: When \`minVersion\` in \`.craft.yml\` is >= 2.21.0 and no \`preReleaseCommand\` is defined, Craft switches from running the default \`bash scripts/bump-version.sh\` to automatic version bumping based on configured publish targets. If the only target is \`github\` (which doesn't support auto-bump — only npm, pypi, crates, gem, pub-dev, hex, nuget do), the version bump silently does nothing. The release gets tagged with unbumped files. Fix: explicitly set \`preReleaseCommand: bash scripts/bump-version.sh\` in \`.craft.yml\` when using a custom bump script with targets that don't support auto-bump. This caused the 26.2.0 release to ship with \`:nightly\` image tags. - -* **Streaming formatters can't use marked-terminal incrementally — tables need all rows**: Markdown tables rendered through marked-terminal (via cli-table3) require all rows upfront to compute column widths. You cannot incrementally render one row at a time through \`renderMarkdown()\`. For streaming/polling formatters like \`formatLogRow\`, \`formatTraceRow\`, \`formatLogsHeader\` that write row-by-row, two approaches: (1) TTY mode: keep current padded text with ANSI colors for efficient incremental display, (2) non-TTY mode: emit raw markdown table row syntax (\`| col | col |\`) which is independently readable without width calculation. Individual cell \*values\* can still be styled via \`renderInlineMarkdown()\` (using \`marked.parseInline()\`) — this renders inline markdown like \`\*\*bold\*\*\`, \`\` \`code\` \`\`, and \`\*italic\*\` without block-level wrapping. This lets streaming rows benefit from markdown formatting per-cell without needing the full table pipeline. This means streaming formatters bypass \`renderMarkdown()\` by design but can use the inline variant. - -### Pattern - - -* **CLI UX: auto-correct common user mistakes with stderr warnings instead of hard errors**: When a CLI command can unambiguously detect a common user mistake (like using the wrong separator character), prefer auto-correcting the input and printing a warning to stderr over throwing a hard error. This is safe when: (1) the input is already invalid and would fail anyway, (2) there's no ambiguity in the correction, and (3) the warning goes to stderr so it doesn't interfere with JSON/stdout output. Implementation pattern: normalize inputs at the command level before passing to pure parsing functions, keeping the parsers side-effect-free. The \`gh\` CLI (GitHub CLI) is the UX model — match its conventions. - -* **esbuild metafile output bytes vs input bytes — use output for real size impact**: When analyzing bundle size with esbuild's metafile, \`result.metafile.inputs\` shows raw source file sizes BEFORE minification and tree-shaking — these are misleading for size impact analysis. A 3.3MB input file may contribute 0 bytes to output if tree-shaken. Use \`result.metafile.outputs\[outfile].inputs\` to see actual per-file output contribution after minification. To dump metafile: add \`import { writeFileSync } from 'node:fs'; writeFileSync('/tmp/meta.json', JSON.stringify(result.metafile));\` after the build call, then analyze with \`jq\`. The bundle script at script/bundle.ts generates metafile but doesn't write it to disk by default. - -* **Bun.spawn is writable — use direct assignment for test spying instead of mock.module**: Unlike \`node:child\_process\` imports (which require \`mock.module\` and isolated test files), \`Bun.spawn\` is a writable property on the global \`Bun\` object. Tests can replace it directly in \`beforeEach\`/\`afterEach\` without module-level mocking: \`\`\`typescript let originalSpawn: typeof Bun.spawn; beforeEach(() => { originalSpawn = Bun.spawn; Bun.spawn = ((cmd, \_opts) => ({ exited: Promise.resolve(0), })) as typeof Bun.spawn; }); afterEach(() => { Bun.spawn = originalSpawn; }); \`\`\` This avoids the mock.module bleed problem entirely and works in regular \`test:unit\` files (counts toward Codecov). Used successfully in \`test/commands/cli/upgrade.test.ts\` to test \`runSetupOnNewBinary\` and \`migrateToStandaloneForNightly\` which spawn child processes via \`Bun.spawn\`. - -* **ANSI codes survive marked-terminal + cli-table3 pipeline in table cells**: Pre-rendered ANSI escape codes (e.g., from chalk) embedded in markdown table cell values survive the \`marked\` → \`marked-terminal\` → \`cli-table3\` rendering pipeline. cli-table3 uses \`string-width\` which correctly treats ANSI codes as zero-width for column width calculation. This means you can pre-color individual cell values with chalk/ANSI before embedding them in a markdown table string, and the colors will render correctly in the terminal. Verified experimentally in getsentry/cli with \`marked@15\` + \`marked-terminal@7.3.0\` running under Bun. This enables per-cell semantic coloring (e.g., red for errors, green for resolved) in markdown-rendered tables without needing markdown color syntax (which doesn't exist). - -* **Formatter return type migration: string\[] to string for markdown rendering**: The formatter functions (\`formatLogDetails\`, \`formatTraceSummary\`, \`formatOrgDetails\`, \`formatProjectDetails\`, \`formatIssueDetails\`) were migrated from returning \`string\[]\` (array of lines) to returning \`string\` (rendered markdown). When updating tests for this migration: (1) remove \`.join("\n")\` calls, (2) replace \`.map(stripAnsi)\` with \`stripAnsi(result)\`, (3) replace \`Array.isArray(result)\` checks with \`typeof result === "string"\`, (4) replace line-by-line exact match tests (\`lines\[0] === "..."\`, \`lines.some(l => l.includes(...))\`) with content-based checks (\`result.includes(...)\`) since markdown tables render with Unicode box-drawing characters, not padded text columns. The \`writeTable()\` function also changed from text-padded columns to markdown table rendering. - -* **Markdown table structure for marked-terminal: blank header row + separator + data rows**: When building markdown tables for \`marked-terminal\` rendering in this codebase, the pattern is: blank header row (\`| | |\`), then separator (\`|---|---|\`), then data rows (\`| \*\*Label\*\* | value |\`). Putting data rows before the separator produces malformed tables where cell values don't render. This was discovered when the SDK section in \`log.ts\` had the data row before the separator, causing the SDK name to not appear in output. All key-value detail sections (Context, SDK, Trace, Source Location, OpenTelemetry) in \`formatLogDetails\`, \`formatOrgDetails\`, \`formatProjectDetails\`, \`formatIssueDetails\`, and \`formatTraceSummary\` use this pattern. - -* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts follow this pattern: (1) call \`getOrgSdkConfig(orgSlug)\` which resolves the org's regional URL and returns an SDK client config, (2) spread config into the SDK function call: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass result to \`unwrapResult(result, errorContext)\`. There are 14+ usages of this pattern. The \`getOrgSdkConfig\` function (line ~167) calls \`resolveOrgRegion(orgSlug)\` then \`getSdkConfig(regionUrl)\`. Follow this exact pattern when adding new org-scoped endpoints like \`getIssueInOrg\`. - -* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as required or advisory checks. Both typically take 2-3 minutes. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \ --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`. - -* **Isolated test files for mock.module in Bun tests**: In the getsentry/cli repo, tests that use Bun's \`mock.module()\` must be placed in \`test/isolated/\` as separate test files AND run via the separate \`test:isolated\` script (not \`test:unit\`). \`mock.module\` affects the entire module registry and bleeds into ALL subsequently-loaded test files in the same \`bun test\` invocation. Attempting to include \`test/isolated/\` in \`test:unit\` caused 132 test failures from \`node:child\_process\` mock pollution. Consequence: isolated test coverage does NOT appear in Codecov PR patch metrics. For code using \`Bun.spawn\` (not \`node:child\_process\`), prefer direct property assignment (\`Bun.spawn = mockFn\`) in regular test files instead — \`Bun.spawn\` is writable and doesn't require mock.module. - -* **GitHub Actions: skipped needs jobs don't block downstream**: In GitHub Actions, if a job in \`needs\` is skipped due to its \`if\` condition evaluating to false, downstream jobs that depend on it still run (skipped ≠ failed). Output values from the skipped job are empty strings. This enables a pattern where a job like \`nightly-version\` has \`if: github.ref == 'refs/heads/main'\` and downstream jobs like \`build-binary\` list it in \`needs\` — on PRs the nightly job is skipped, outputs are empty, and conditional steps using \`if: needs.nightly-version.outputs.version != ''\` are safely skipped. No need for complex conditional \`needs\` expressions. - -* **OpenCode worktree blocks checkout of main — use stash + new branch instead**: When working in the getsentry/cli repo, \`git checkout main\` fails because main is used by an OpenCode worktree at \`~/.local/share/opencode/worktree/\`. Workaround: stash changes, fetch, create a new branch from origin/main (\`git stash && git fetch && git checkout -b \ origin/main && git stash pop\`), then pop stash. Do NOT try to checkout main directly. This also applies when rebasing onto latest main — you must use \`origin/main\` as the target, never the local \`main\` branch. - -### Preference - - -* **Code style**: User prefers no backwards-compat shims, fix callers directly - -* **General coding preference**: Prefer explicit error handling over silent failures - -* **General coding preference**: Prefer explicit error handling over silent failures - -* **Code style**: User prefers no backwards-compat shims, fix callers directly - -* **.opencode directory should be gitignored**: The \`.opencode/\` directory (used by OpenCode for plans, session data, etc.) should be in \`.gitignore\` and never committed. It was added to \`.gitignore\` alongside \`.idea\` and \`.DS\_Store\` in the editor/tool ignore section. - -* **CI: define shared env vars at workflow level, not per-job**: Reviewer (BYK) prefers defining environment variables that are used by multiple jobs at the workflow-level \`env:\` block rather than repeating them in each job's step-level \`env:\`. GitHub Actions workflow-level \`env\` is inherited by all jobs and steps. Example: \`COMMIT\_TIMESTAMP\` was moved from being defined in both \`build-binary\` and \`publish-nightly\` to a single workflow-level declaration. - -* **CI scripts: prefer jq/sed over node -e for JSON manipulation**: Reviewer (BYK) prefers using standard Unix tools (\`jq\`, \`sed\`, \`awk\`) over \`node -e\` for simple JSON manipulation in CI workflow scripts. For example, reading/modifying package.json version: \`jq -r .version package.json\` to read, \`jq --arg v "$NEW" '.version = $v' package.json > tmp && mv tmp package.json\` to write. This avoids requiring Node.js to be installed in CI steps that only need basic JSON operations, and is more readable for shell-centric workflows. - diff --git a/src/lib/formatters/colors.ts b/src/lib/formatters/colors.ts index a5067c91..7e673417 100644 --- a/src/lib/formatters/colors.ts +++ b/src/lib/formatters/colors.ts @@ -9,7 +9,7 @@ import type { IssueLevel, IssueStatus } from "../../types/index.js"; // Color Palette (Full Sentinel palette) -const COLORS = { +export const COLORS = { red: "#fe4144", green: "#83da90", yellow: "#FDB81B", diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index ad039d0f..e9721cc6 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -26,7 +26,7 @@ import chalk from "chalk"; import { highlight as cliHighlight } from "cli-highlight"; import { marked, type Token, type Tokens } from "marked"; -import { muted, terminalLink } from "./colors.js"; +import { COLORS, muted, terminalLink } from "./colors.js"; import { type Alignment, renderTextTable } from "./text-table.js"; // ──────────────────────────── Environment ───────────────────────────── @@ -185,17 +185,6 @@ export function divider(width = 80): string { // ──────────────────────── Inline token rendering ───────────────────── -/** Sentinel-inspired color palette */ -const COLORS = { - red: "#fe4144", - green: "#83da90", - yellow: "#FDB81B", - blue: "#226DFC", - magenta: "#FF45A8", - cyan: "#79B8FF", - muted: "#898294", -} as const; - /** * Semantic HTML color tags supported in markdown strings. * From 0088ec582a1e3ec52f918cd7056e65b48debab47 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 02:09:05 +0000 Subject: [PATCH 39/52] fix(formatters): strip color tags in plain output, escape angle brackets in mdKvTable - mdRow: strip color tags from cells in plain mode so ERROR becomes just ERROR in piped/CI output - buildMarkdownTable: strip color tags before escaping cells to prevent double-encoding ( -> <red>) in plain mode - mdKvTable: escape < and > to </> so user content like 'Expected ' isn't silently dropped by the markdown parser - Export stripColorTags for use in table.ts --- AGENTS.md | 137 +++++++++++++++++++++++++++++++++ src/lib/formatters/markdown.ts | 6 +- src/lib/formatters/table.ts | 3 +- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb92cb28..de43c75d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -709,3 +709,140 @@ mock.module("./some-module", () => ({ * **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. + + +## Long-term Knowledge + +### Architecture + + +* **@sentry/node-core pulls in @apm-js-collab/code-transformer (3.3MB) but it's tree-shaken**: The dependency chain \`@sentry/node\` → \`@sentry/node-core\` → \`@apm-js-collab/tracing-hooks\` → \`@apm-js-collab/code-transformer\` adds a 3.3MB input file to the esbuild bundle analysis. However, esbuild fully tree-shakes it out — it contributes 0 bytes to the final npm bundle output. This was verified by checking both the released 0.13.0 bundle and the current build: neither contains any \`code-transformer\` or \`apm-js-collab\` strings. Don't be alarmed by its presence in metafile inputs. + +* **GitHub Packages has no generic/raw file registry — only typed registries**: GitHub Packages only supports typed registries (Container/npm/Maven/NuGet/RubyGems) — there is no generic file store where you can upload a binary and get a download URL. For distributing arbitrary binaries: the Container registry (ghcr.io) via ORAS works but requires a 3-step download (token → manifest → blob). The npm registry at \`npm.pkg.github.com\` requires authentication even for public packages, making it unsuitable for install scripts. This means for binary distribution on GitHub without GitHub Releases, GHCR+ORAS is the only viable GitHub Packages option. + +* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: The \`cli.sentry.dev\` domain points to GitHub Pages for the getsentry/cli repo, confirmed by the CNAME file on the gh-pages branch containing \`cli.sentry.dev\`. The branch contains the Astro/Starlight docs site output plus the install script at \`/install\`. Craft's gh-pages target manages this branch, wiping and replacing all content on each stable release. The docs are built as a zip artifact named \`gh-pages.zip\` (matched by Craft's \`DEFAULT\_DEPLOY\_ARCHIVE\_REGEX: /^(?:.+-)?gh-pages\\.zip$/\`). + +* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The Sentry CLI npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses Node.js 22's built-in \`node:sqlite\` module. The \`require('node:sqlite')\` happens during module loading (via esbuild's inject option) — before any user code runs. package.json declares \`engines: { node: '>=22' }\` but pnpm/npm don't enforce this by default. A runtime version guard in the esbuild banner catches this early with a clear error message pointing users to either upgrade Node.js or use the standalone binary (\`curl -fsSL https://cli.sentry.dev/install | bash\`). + +* **parseSentryUrl does not handle subdomain-style SaaS URLs**: The URL parser in src/lib/sentry-url-parser.ts handles both path-based (\`/organizations/{org}/...\`) and subdomain-style (\`https://{org}.sentry.io/issues/123/\`) SaaS URLs. The \`matchSubdomainOrg()\` function extracts the org from the hostname when it ends with \`.sentry.io\`, supports \`/issues/{id}/\`, \`/issues/{id}/events/{eventId}/\`, and \`/traces/{traceId}/\` paths. Region subdomains (\`us\`, \`de\`) are filtered out by requiring org slugs to be longer than 2 characters. Confirmed against getsentry/sentry codebase: the subdomain IS the org slug directly (e.g., \`my-org.sentry.io\`), NOT a fixed prefix like \`o.sentry.io\`. The Sentry backend builds permalinks via \`organization.absolute\_url()\` → \`generate\_organization\_url(slug)\` using the \`system.organization-base-hostname\` template \`{slug}.sentry.io\` (src/sentry/organizations/absolute\_url.py:72-92). When customer domains are enabled (production SaaS), \`customer\_domain\_path()\` strips \`/organizations/{slug}/\` from paths. Region subdomains are filtered by Sentry's \`subdomain\_is\_region()\`, aligning with the \`org.length <= 2\` check. Self-hosted uses path-based: \`/organizations/{org\_slug}/issues/{id}/\`. + +* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution now uses a multi-step approach in \`resolveNumericIssue()\` (extracted from \`resolveIssue\` to reduce cognitive complexity). Resolution order: (1) \`resolveOrg({ cwd })\` tries DSN/env/config for org context, (2) if org found, uses \`getIssueInOrg(org, id)\` with region routing, (3) if no org, falls back to unscoped \`getIssue(id)\`, (4) extracts org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. The \`explicit-org-numeric\` case now uses \`getIssueInOrg(parsed.org, id)\` instead of the unscoped endpoint. \`getIssueInOrg\` was added to api-client.ts using the SDK's \`retrieveAnIssue\` with the standard \`getOrgSdkConfig + unwrapResult\` pattern. The \`resolveOrgAndIssueId\` wrapper (used by \`explain\`/\`plan\`) no longer throws "Organization is required" for bare numeric IDs when the permalink contains the org slug. + +* **Install script nightly channel support**: The curl install script (\`cli.sentry.dev/install\`) supports \`--channel nightly\`. For nightly: downloads the binary directly from the \`nightly\` release tag (no version.json fetch needed — just uses \`nightly\` as both download tag and display string). Passes \`--channel nightly\` to \`$binary cli setup --install --method curl --channel nightly\` so the channel is persisted in DB. Usage: \`curl -fsSL https://cli.sentry.dev/install | bash -s -- --channel nightly\`. The install script does NOT fetch version.json — that's only used by the upgrade/version-check flow to compare versions. + +### Decision + + +* **Nightly release: delete-then-upload instead of clobber for asset management**: The publish-nightly CI job deletes ALL existing release assets before uploading new ones, rather than using \`gh release upload --clobber\`. This ensures that if asset names change (e.g., removing a platform or renaming files), stale assets don't linger on the nightly release indefinitely. Pattern: \`gh release view nightly --json assets --jq '.assets\[].name' | while read -r name; do gh release delete-asset nightly "$name" --yes; done\` followed by \`gh release upload nightly \\` (no --clobber needed since assets were cleared). + +* **Raw markdown output for non-interactive terminals, rendered for TTY**: Design decision for getsentry/cli: output raw CommonMark markdown when stdout is not interactive (piped, redirected, CI), and only render through marked-terminal when a human is looking at it in a TTY. Detection: \`const isInteractive = process.stdout.isTTY\` — no \`process.env.CI\` check needed, since if a CI runner allocates a pseudo-TTY it can render the styled output fine. Override env vars with precedence: \`SENTRY\_PLAIN\_OUTPUT\` (most specific) > \`NO\_COLOR\` > auto-detect via \`isTTY\`. \`SENTRY\_PLAIN\_OUTPUT=1\` forces raw markdown even on TTY; \`SENTRY\_PLAIN\_OUTPUT=0\` forces rendered even when piped. \`NO\_COLOR\` (no-color.org standard) also triggers plain mode since rendered output is ANSI-colored. Chalk auto-disables colors when piped, so pre-embedded ANSI codes become plain text in raw mode. Output modes: \`--json\` → JSON (unchanged), TTY → rendered markdown via marked-terminal, non-TTY → raw CommonMark. For streaming formatters (log/trace row-by-row output), TTY keeps current ANSI-colored padded text for efficient incremental display, while non-TTY emits markdown table rows with header+separator on first write, producing valid markdown files when redirected. + +### Gotcha + + +* **React useState async pitfall**: React useState setter is async — reading state immediately after setState returns stale value in dashboard components + +* **TypeScript strict mode caveat**: TypeScript strict null checks require explicit undefined handling + +* **git notes are lost on commit amend — must re-attach to new SHA**: Git notes are attached to a specific commit SHA. When you \`git commit --amend\`, the old commit is replaced with a new one (different SHA), and the note attached to the old SHA becomes orphaned. After amending, you must re-add the note to the new commit with \`git notes add\` targeting the new SHA. This also affects \`git push --force\` of notes refs — the remote note ref still points to the old SHA. + +* **pnpm overrides with version-range keys don't force upgrades of already-compatible resolutions**: pnpm overrides with version-range selectors like \`"minimatch@>=10.0.0 <10.2.1": ">=10.2.1"\` do NOT work as expected for forcing upgrades of transitive deps that already satisfy their parent's semver range. If a parent requests \`^10.1.1\` and pnpm resolves \`10.1.1\`, the override key \`>=10.0.0 <10.2.1\` should match but doesn't reliably force re-resolution — even with \`pnpm install --force\`. The workaround is a blanket override without a version selector: \`"minimatch": ">=10.2.1"\`. This is only safe when ALL consumers are on the same major version line (otherwise it's a breaking change). Verify first with \`pnpm why \\` that no other major versions exist in the tree before using a blanket override. + +* **pnpm overrides with >= can cross major versions — use ^ to constrain**: When using pnpm overrides to patch a transitive dependency vulnerability, \`"ajv@<6.14.0": ">=6.14.0"\` will resolve to the latest ajv (v8.x), not the latest 6.x. ajv v6 and v8 have incompatible APIs — this broke eslint (\`@eslint/eslintrc\` calls \`ajv\` v6 API, crashes with \`Cannot set properties of undefined (setting 'defaultMeta')\` on v8). Fix: use \`"ajv@<6.14.0": "^6.14.0"\` to constrain within the same major. This applies to any override where the target package has multiple major versions in the registry — always use \`^\` (or \`~\`) instead of \`>=\` to stay within the compatible major line. + +* **marked-terminal unconditionally imports cli-highlight and node-emoji — no tree-shaking possible**: marked-terminal has static top-level imports of \`cli-highlight\` (which pulls in highlight.js, ~570KB minified output) and \`node-emoji\` (which pulls in emojilib, ~208KB minified output) at lines 5-6 of its index.js. These are unconditional — there's no config option to disable them, and the \`emoji: false\` option only skips the emoji replacement function but doesn't prevent the import. esbuild cannot tree-shake static imports. This means any bundle including marked-terminal will grow by ~970KB (highlight.js + emojilib + parse5). To avoid this in a CLI bundle, you'd need to either: (1) write a custom marked renderer using only chalk, (2) fork marked-terminal with dynamic imports, or (3) use esbuild's \`external\` option (but then those packages must be available at runtime). + +* **pnpm-lock.yaml merge conflicts: regenerate don't manually merge**: When \`pnpm-lock.yaml\` has merge conflicts, never try to manually resolve the conflict markers. Instead: (1) \`git checkout --theirs pnpm-lock.yaml\` (or \`--ours\` depending on which package.json changes you want as base), (2) run \`pnpm install\` to regenerate the lockfile incorporating both sides' \`package.json\` changes (including overrides). This produces a clean lockfile that reflects the merged dependency state. Manual conflict resolution in lockfiles is error-prone and unnecessary since pnpm can regenerate it deterministically. + +* **git stash pop after merge can cause second conflict on same file**: When you stash local changes, merge, then \`git stash pop\`, the stash apply can create a NEW conflict on the same file that was just conflict-resolved in the merge. This happens because the stash was based on the pre-merge state and conflicts with the post-merge-resolution content. The resolution requires a second manual conflict resolution pass. To avoid: if stashed changes are lore/auto-generated content, consider just dropping the stash and re-running the generation tool after merge instead of popping. + +* **pnpm overrides become stale when dependency tree changes — audit with pnpm why**: pnpm overrides can become orphaned when the dependency tree changes. For example, removing a package that was the sole consumer of a transitive dep (like removing \`@sentry/typescript\` which pulled in \`tslint\` which was the only consumer of \`diff\`), or upgrading a package that switches to a differently-named dependency (like \`minimatch@10.2.4\` switching from \`@isaacs/brace-expansion\` to the unscoped \`brace-expansion\`). Orphaned overrides sit silently in package.json and could unexpectedly constrain versions if a future dependency reintroduces the package name. After removing packages or upgrading dependencies that change the transitive tree, audit overrides with \`pnpm why \\` to verify each override still has consumers in the resolved tree. Remove any that return empty results. + +* **ESM modules prevent vi.spyOn of child\_process.spawnSync — use test subclass pattern**: In Vitest with ESM, you cannot spy on exports from Node built-in modules like \`child\_process.spawnSync\` — it throws \`Cannot spy on export. Module namespace is not configurable in ESM\`. The workaround for testing code that calls \`spawnSync\` (like npm version checks) is to create a test subclass that overrides the method calling \`spawnSync\` and injects controllable values. Example: \`TestNpmTarget\` overrides \`checkRequirements()\` to set \`this.npmVersion\` from a static \`mockVersion\` field instead of calling \`spawnSync(npm, \['--version'])\`. This avoids the ESM limitation while keeping test isolation. \`vi.mock\` at module level is another option but affects all tests in the file. + +* **npm OIDC only works for publish — npm info/view still needs traditional auth**: Per npm docs: "OIDC authentication is currently limited to the publish operation. Other npm commands such as install, view, or access still require traditional authentication methods." This means \`npm info \ version\` (used by Craft's \`getLatestVersion()\`) cannot use OIDC tokens. For public packages this is fine — \`npm info\` works without auth. For private packages, a read-only \`NPM\_TOKEN\` is still needed alongside OIDC. Craft handles this by: using token auth for \`getLatestVersion()\` when a token is available, running unauthenticated otherwise, and warning + skipping version checks for private packages in OIDC mode without a token. + +* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` + +* **marked and marked-terminal must be devDependencies in bundled CLI projects**: In the getsentry/cli project, all npm packages used at runtime must be bundled by the build step (esbuild). Packages like \`marked\` and \`marked-terminal\` belong in \`devDependencies\`, not \`dependencies\`. The CI \`check:deps\` step enforces this — anything in \`dependencies\` that isn't a true runtime requirement (native addon, bin entry) will fail the check. This applies to all packages consumed only through the bundle. + +* **CodeQL flags incomplete markdown cell escaping — must escape backslash before pipe**: When escaping user content for markdown table cells, replacing only \`|\` with \`\\|\` triggers CodeQL's "Incomplete string escaping or encoding" alert (high severity). The fix is to escape backslashes first, then pipes: \`str.replace(/\\\\/g, "\\\\\\\\" ).replace(/\\|/g, "\\\\|")\`. In getsentry/cli this is centralized as \`escapeMarkdownCell()\` in \`src/lib/formatters/markdown.ts\`. All formatters that build markdown table rows (human.ts, log.ts, trace.ts) must use this helper instead of inline \`.replace()\` calls. + +* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: In getsentry/cli tests, \`mockFetch()\` sets \`globalThis.fetch\` to a new function. Calling it twice replaces the first mock entirely. A common bug: calling \`mockBinaryDownload()\` then \`mockGitHubVersion()\` means the binary download URL hits the version mock (returns 404). Fix: create a single unified fetch mock that handles ALL endpoints the test needs (version API, binary download, npm registry, version.json). Pattern: \`\`\`typescript mockFetch(async (url) => { const urlStr = String(url); if (urlStr.includes('releases/latest')) return versionResponse; if (urlStr.includes('version.json')) return nightlyResponse; return new Response(gzipped, { status: 200 }); // binary download }); \`\`\` This caused multiple test failures when trying to test the full upgrade→download→setup pipeline. + +* **marked-terminal@7 peer dependency requires marked < 16**: \`marked-terminal@7.3.0\` declares a peer dependency of \`marked@>=1 <16\`. Installing \`marked@17\` triggers a peer dependency warning and may cause runtime issues. Use \`marked@^15\` (e.g., \`marked@15.0.12\`) for compatibility. In Bun, peer dependency warnings don't block installation but the version mismatch can cause subtle breakage. + +* **ghcr.io blob download: curl -L breaks because auth header leaks to Azure redirect**: When downloading blobs from ghcr.io via raw HTTP, the registry returns a 307 redirect to a signed Azure Blob Storage URL (\`pkg-containers.githubusercontent.com\`). Using \`curl -L\` with the \`Authorization: Bearer \\` header fails with 404 because curl forwards the auth header to Azure, which rejects it. Fix: don't use \`-L\`. Instead, extract the redirect URL and follow it separately: \`\`\`bash REDIR=$(curl -s -w '\n%{redirect\_url}' -o /dev/null -H "Authorization: Bearer $TOKEN" "$BLOB\_URL" | tail -1) curl -sf "$REDIR" -o output.file \`\`\` This affects any tool using raw HTTP to pull from ghcr.io — the ORAS CLI handles this internally but install scripts using plain curl need the two-step approach. + +* **GHCR packages created as private by default — must manually make public once**: When first pushing to \`ghcr.io/getsentry/cli\` via ORAS or Docker, the container package is created with \`visibility: private\`. Anonymous pulls fail until the package is made public. This is a one-time manual step via GitHub UI at the package settings page (Danger Zone → Change visibility → Public). The GitHub Packages API for changing visibility requires org admin scopes that aren't available via \`gh auth\` by default. For the getsentry/cli nightly distribution, the package has already been made public. + +* **Craft gh-pages target wipes entire branch on publish**: Craft's \`GhPagesTarget.commitArchiveToBranch()\` runs \`git rm -r -f .\` before extracting the docs archive, deleting ALL existing files on the gh-pages branch. Any additional files placed there (e.g., nightly binaries in a \`nightly/\` directory) would be wiped on every stable release. The \`cli.sentry.dev\` site is served from the gh-pages branch (CNAME file confirms this). If using gh-pages for hosting non-docs files, either accept the brief outage window until the next main push re-adds them, add a \`postReleaseCommand\` in \`.craft.yml\` to restore the files, or use a different hosting mechanism entirely. + +* **upload-artifact strips directory prefix from glob paths**: When \`actions/upload-artifact@v4\` uploads with \`path: dist-bin/sentry-\*\`, it strips the \`dist-bin/\` directory prefix — the artifact stores files as \`sentry-linux-x64\`, \`sentry-linux-x64.gz\`, etc. at the root level. When \`actions/download-artifact@v4\` with \`merge-multiple: true\` and \`path: artifacts\` downloads these, files end up at \`artifacts/sentry-\*.gz\`, NOT \`artifacts/dist-bin/sentry-\*.gz\`. This caused the publish-nightly job to fail with \`no matches found for artifacts/dist-bin/\*.gz\`. The fix was changing the glob to \`artifacts/\*.gz\`. + +* **GitHub immutable releases prevent rolling nightly tag pattern**: The getsentry/cli repo has immutable releases enabled (org/repo setting, will NOT be turned off). This means: (1) once a release is published, its assets cannot be modified or deleted, (2) a tag used by a published release can NEVER be reused, even after deleting the release — \`gh release delete nightly --cleanup-tag\` followed by \`gh release create nightly\` fails with \`tag\_name was used by an immutable release\`. Draft releases ARE mutable but use unpredictable \`/download/untagged-xxx/\` URLs instead of tag-based URLs, and publishing a draft with a previously-used tag also fails. This breaks the original nightly design of a single rolling \`nightly\` tag. The \`nightly\` tag is now permanently poisoned. New approach needed: per-version release tags (e.g., \`0.13.0-dev.1772062077\`) with API-based discovery of the latest prerelease. + +* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: In getsentry/cli tests, \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` inside the repo tree. When code calls \`detectDsn(cwd)\` with this temp dir as cwd (e.g., via \`resolveOrg({ cwd })\`), \`findProjectRoot\` walks up from \`.test-tmp/prefix-xxx\` and finds the repo's \`.git\` directory, causing DSN detection to scan the actual source code for Sentry DSNs. This can trigger network calls that hit test fetch mocks (returning 404s or unexpected responses), leading to 5-second test timeouts. Fix: always use \`useTestConfigDir(prefix, { isolateProjectRoot: true })\` when the test exercises any code path that might call \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\` with the config dir as cwd. The \`isolateProjectRoot\` option creates a \`.git\` directory inside the temp dir, stopping the upward walk immediately. + +* **mock.module bleeds across Bun test files — never include test/isolated/ in test:unit**: In Bun's test runner, \`mock.module()\` pollutes the shared module registry for ALL subsequently-loaded test files in the same \`bun test\` invocation. Including \`test/isolated/\` in \`test:unit\` caused 132 failures because the \`node:child\_process\` mock leaked into DB tests, config tests, and others that transitively import child\_process. The \`test:isolated\` script MUST remain separate from \`test:unit\`. This also means isolated test coverage does NOT appear in Codecov PR patch coverage — only \`test:unit\` feeds lcov. Accept that code paths requiring \`mock.module\` (e.g., \`node:child\_process spawn\` wrappers like \`runCommand\`, \`isInstalledWith\`, \`executeUpgradeHomebrew\`, \`executeUpgradePackageManager\`) will have zero Codecov coverage. + +* **GitHub Actions: use deterministic timestamps across jobs, not Date.now()**: When multiple GitHub Actions jobs need to agree on a timestamp-based version string (e.g., nightly builds where \`build-binary\` bakes the version in and \`publish-nightly\` creates version.json), never use \`Date.now()\` or \`Math.floor(Date.now()/1000)\` independently in each job. Jobs run at different times, producing different timestamps. Instead, derive the timestamp from a shared deterministic source like \`github.event.head\_commit.timestamp\` (the commit time). Convert to Unix seconds: \`date -d '\' +%s\`. This ensures all matrix entries and downstream jobs produce the same version string. + +* **version-check.test.ts has pre-existing unmocked fetch calls**: In getsentry/cli, \`test/lib/version-check.test.ts\` makes unmocked fetch calls to \`https://api.github.com/repos/getsentry/cli/releases/latest\` and to Sentry's ingest endpoint. These are pre-existing issues that produce \`\[TEST] Unexpected fetch call\` warnings in test output but don't cause test failures. Not related to any specific PR — existed before the Homebrew changes. + +* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: The @sentry/api SDK creates a \`Request\` object with Content-Type set in its headers, then calls \`\_fetch(request2)\` with only one argument (no init). In sentry-client.ts's \`authenticatedFetch\`, \`init\` is undefined, so \`prepareHeaders(init, token)\` creates empty headers from \`new Headers(undefined)\`. When \`fetch(Request, {headers})\` is called, Node.js strictly follows the spec where init headers replace Request headers entirely — stripping Content-Type. This causes HTTP 415 'Unsupported media type' errors on POST requests (e.g., startSeerIssueFix). Bun may merge headers instead, so the bug only manifests under Node.js runtime. Fix: \`prepareHeaders\` must accept the input parameter and fall back to \`input.headers\` when \`init\` is undefined. Also fix method extraction: \`init?.method\` returns undefined for Request-only calls, defaulting to 'GET' even for POST requests. + +* **Several commands bypass telemetry by importing buildCommand from @stricli/core directly**: src/lib/command.ts wraps Stricli's buildCommand to auto-capture flag/arg telemetry via Sentry. But trace/list, trace/view, log/view, api.ts, and help.ts import buildCommand directly from @stricli/core, silently skipping telemetry. Fix: change their imports to use ../../lib/command.js. Consider adding a Biome lint rule (noRestrictedImports equivalent) to prevent future regressions. + +* **@sentry/api SDK issues filed to sentry-api-schema repo**: All @sentry/api SDK issues should be filed to https://github.com/getsentry/sentry-api-schema/ and assigned to @MathurAditya724. Two known issues: (1) unwrapResult() discards Link response headers, silently truncating listTeams/listRepositories at 100 items and preventing cursor pagination. (2) No paginated variants exist for team/repo/issue list endpoints, forcing callers to bypass the SDK with raw requests. + +* **Esbuild banner template literal double-escape for newlines**: When using esbuild's \`banner\` option with a TypeScript template literal containing string literals that need \`\n\` escape sequences: use \`\\\\\\\n\` in the TS source. The chain is: TS template literal \`\\\\\\\n\` → esbuild banner output \`\\\n\` → JS runtime interprets as newline. Using only \`\\\n\` in the TS source produces a literal newline character inside a JS double-quoted string, which is a SyntaxError. This applies to any esbuild banner/footer that injects JS strings containing escape sequences. Discovered in script/bundle.ts for the Node.js version guard error message. + +* **Craft minVersion >= 2.21.0 silently disables custom bump-version.sh**: When \`minVersion\` in \`.craft.yml\` is >= 2.21.0 and no \`preReleaseCommand\` is defined, Craft switches from running the default \`bash scripts/bump-version.sh\` to automatic version bumping based on configured publish targets. If the only target is \`github\` (which doesn't support auto-bump — only npm, pypi, crates, gem, pub-dev, hex, nuget do), the version bump silently does nothing. The release gets tagged with unbumped files. Fix: explicitly set \`preReleaseCommand: bash scripts/bump-version.sh\` in \`.craft.yml\` when using a custom bump script with targets that don't support auto-bump. This caused the 26.2.0 release to ship with \`:nightly\` image tags. + +* **Streaming formatters can't use marked-terminal incrementally — tables need all rows**: Markdown tables rendered through marked-terminal (via cli-table3) require all rows upfront to compute column widths. You cannot incrementally render one row at a time through \`renderMarkdown()\`. For streaming/polling formatters like \`formatLogRow\`, \`formatTraceRow\`, \`formatLogsHeader\` that write row-by-row, two approaches: (1) TTY mode: keep current padded text with ANSI colors for efficient incremental display, (2) non-TTY mode: emit raw markdown table row syntax (\`| col | col |\`) which is independently readable without width calculation. Individual cell \*values\* can still be styled via \`renderInlineMarkdown()\` (using \`marked.parseInline()\`) — this renders inline markdown like \`\*\*bold\*\*\`, \`\` \`code\` \`\`, and \`\*italic\*\` without block-level wrapping. This lets streaming rows benefit from markdown formatting per-cell without needing the full table pipeline. This means streaming formatters bypass \`renderMarkdown()\` by design but can use the inline variant. + +### Pattern + + +* **Kubernetes deployment pattern**: Use helm charts for Kubernetes deployments with resource limits + +* **CLI UX: auto-correct common user mistakes with stderr warnings instead of hard errors**: When a CLI command can unambiguously detect a common user mistake (like using the wrong separator character), prefer auto-correcting the input and printing a warning to stderr over throwing a hard error. This is safe when: (1) the input is already invalid and would fail anyway, (2) there's no ambiguity in the correction, and (3) the warning goes to stderr so it doesn't interfere with JSON/stdout output. Implementation pattern: normalize inputs at the command level before passing to pure parsing functions, keeping the parsers side-effect-free. The \`gh\` CLI (GitHub CLI) is the UX model — match its conventions. + +* **esbuild metafile output bytes vs input bytes — use output for real size impact**: When analyzing bundle size with esbuild's metafile, \`result.metafile.inputs\` shows raw source file sizes BEFORE minification and tree-shaking — these are misleading for size impact analysis. A 3.3MB input file may contribute 0 bytes to output if tree-shaken. Use \`result.metafile.outputs\[outfile].inputs\` to see actual per-file output contribution after minification. To dump metafile: add \`import { writeFileSync } from 'node:fs'; writeFileSync('/tmp/meta.json', JSON.stringify(result.metafile));\` after the build call, then analyze with \`jq\`. The bundle script at script/bundle.ts generates metafile but doesn't write it to disk by default. + +* **Bun.spawn is writable — use direct assignment for test spying instead of mock.module**: Unlike \`node:child\_process\` imports (which require \`mock.module\` and isolated test files), \`Bun.spawn\` is a writable property on the global \`Bun\` object. Tests can replace it directly in \`beforeEach\`/\`afterEach\` without module-level mocking: \`\`\`typescript let originalSpawn: typeof Bun.spawn; beforeEach(() => { originalSpawn = Bun.spawn; Bun.spawn = ((cmd, \_opts) => ({ exited: Promise.resolve(0), })) as typeof Bun.spawn; }); afterEach(() => { Bun.spawn = originalSpawn; }); \`\`\` This avoids the mock.module bleed problem entirely and works in regular \`test:unit\` files (counts toward Codecov). Used successfully in \`test/commands/cli/upgrade.test.ts\` to test \`runSetupOnNewBinary\` and \`migrateToStandaloneForNightly\` which spawn child processes via \`Bun.spawn\`. + +* **ANSI codes survive marked-terminal + cli-table3 pipeline in table cells**: Pre-rendered ANSI escape codes (e.g., from chalk) embedded in markdown table cell values survive the \`marked\` → \`marked-terminal\` → \`cli-table3\` rendering pipeline. cli-table3 uses \`string-width\` which correctly treats ANSI codes as zero-width for column width calculation. This means you can pre-color individual cell values with chalk/ANSI before embedding them in a markdown table string, and the colors will render correctly in the terminal. Verified experimentally in getsentry/cli with \`marked@15\` + \`marked-terminal@7.3.0\` running under Bun. This enables per-cell semantic coloring (e.g., red for errors, green for resolved) in markdown-rendered tables without needing markdown color syntax (which doesn't exist). + +* **Formatter return type migration: string\[] to string for markdown rendering**: The formatter functions (\`formatLogDetails\`, \`formatTraceSummary\`, \`formatOrgDetails\`, \`formatProjectDetails\`, \`formatIssueDetails\`) were migrated from returning \`string\[]\` (array of lines) to returning \`string\` (rendered markdown). When updating tests for this migration: (1) remove \`.join("\n")\` calls, (2) replace \`.map(stripAnsi)\` with \`stripAnsi(result)\`, (3) replace \`Array.isArray(result)\` checks with \`typeof result === "string"\`, (4) replace line-by-line exact match tests (\`lines\[0] === "..."\`, \`lines.some(l => l.includes(...))\`) with content-based checks (\`result.includes(...)\`) since markdown tables render with Unicode box-drawing characters, not padded text columns. The \`writeTable()\` function also changed from text-padded columns to markdown table rendering. + +* **Markdown table structure for marked-terminal: blank header row + separator + data rows**: When building markdown tables for \`marked-terminal\` rendering in this codebase, the pattern is: blank header row (\`| | |\`), then separator (\`|---|---|\`), then data rows (\`| \*\*Label\*\* | value |\`). Putting data rows before the separator produces malformed tables where cell values don't render. This was discovered when the SDK section in \`log.ts\` had the data row before the separator, causing the SDK name to not appear in output. All key-value detail sections (Context, SDK, Trace, Source Location, OpenTelemetry) in \`formatLogDetails\`, \`formatOrgDetails\`, \`formatProjectDetails\`, \`formatIssueDetails\`, and \`formatTraceSummary\` use this pattern. + +* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts follow this pattern: (1) call \`getOrgSdkConfig(orgSlug)\` which resolves the org's regional URL and returns an SDK client config, (2) spread config into the SDK function call: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass result to \`unwrapResult(result, errorContext)\`. There are 14+ usages of this pattern. The \`getOrgSdkConfig\` function (line ~167) calls \`resolveOrgRegion(orgSlug)\` then \`getSdkConfig(regionUrl)\`. Follow this exact pattern when adding new org-scoped endpoints like \`getIssueInOrg\`. + +* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as required or advisory checks. Both typically take 2-3 minutes. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \ --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`. + +* **Isolated test files for mock.module in Bun tests**: In the getsentry/cli repo, tests that use Bun's \`mock.module()\` must be placed in \`test/isolated/\` as separate test files AND run via the separate \`test:isolated\` script (not \`test:unit\`). \`mock.module\` affects the entire module registry and bleeds into ALL subsequently-loaded test files in the same \`bun test\` invocation. Attempting to include \`test/isolated/\` in \`test:unit\` caused 132 test failures from \`node:child\_process\` mock pollution. Consequence: isolated test coverage does NOT appear in Codecov PR patch metrics. For code using \`Bun.spawn\` (not \`node:child\_process\`), prefer direct property assignment (\`Bun.spawn = mockFn\`) in regular test files instead — \`Bun.spawn\` is writable and doesn't require mock.module. + +* **GitHub Actions: skipped needs jobs don't block downstream**: In GitHub Actions, if a job in \`needs\` is skipped due to its \`if\` condition evaluating to false, downstream jobs that depend on it still run (skipped ≠ failed). Output values from the skipped job are empty strings. This enables a pattern where a job like \`nightly-version\` has \`if: github.ref == 'refs/heads/main'\` and downstream jobs like \`build-binary\` list it in \`needs\` — on PRs the nightly job is skipped, outputs are empty, and conditional steps using \`if: needs.nightly-version.outputs.version != ''\` are safely skipped. No need for complex conditional \`needs\` expressions. + +* **OpenCode worktree blocks checkout of main — use stash + new branch instead**: When working in the getsentry/cli repo, \`git checkout main\` fails because main is used by an OpenCode worktree at \`~/.local/share/opencode/worktree/\`. Workaround: stash changes, fetch, create a new branch from origin/main (\`git stash && git fetch && git checkout -b \ origin/main && git stash pop\`), then pop stash. Do NOT try to checkout main directly. This also applies when rebasing onto latest main — you must use \`origin/main\` as the target, never the local \`main\` branch. + +### Preference + + +* **General coding preference**: Prefer explicit error handling over silent failures + +* **Code style**: User prefers no backwards-compat shims, fix callers directly + +* **Code style**: User prefers no backwards-compat shims, fix callers directly + +* **General coding preference**: Prefer explicit error handling over silent failures + +* **.opencode directory should be gitignored**: The \`.opencode/\` directory (used by OpenCode for plans, session data, etc.) should be in \`.gitignore\` and never committed. It was added to \`.gitignore\` alongside \`.idea\` and \`.DS\_Store\` in the editor/tool ignore section. + +* **CI: define shared env vars at workflow level, not per-job**: Reviewer (BYK) prefers defining environment variables that are used by multiple jobs at the workflow-level \`env:\` block rather than repeating them in each job's step-level \`env:\`. GitHub Actions workflow-level \`env\` is inherited by all jobs and steps. Example: \`COMMIT\_TIMESTAMP\` was moved from being defined in both \`build-binary\` and \`publish-nightly\` to a single workflow-level declaration. + +* **CI scripts: prefer jq/sed over node -e for JSON manipulation**: Reviewer (BYK) prefers using standard Unix tools (\`jq\`, \`sed\`, \`awk\`) over \`node -e\` for simple JSON manipulation in CI workflow scripts. For example, reading/modifying package.json version: \`jq -r .version package.json\` to read, \`jq --arg v "$NEW" '.version = $v' package.json > tmp && mv tmp package.json\` to write. This avoids requiring Node.js to be installed in CI steps that only need basic JSON operations, and is more readable for shell-centric workflows. + diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index e9721cc6..ccd20f11 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -138,7 +138,7 @@ export function mdTableHeader(cols: readonly string[]): string { */ export function mdRow(cells: readonly string[]): string { if (isPlainOutput()) { - return `| ${cells.join(" | ")} |\n`; + return `| ${cells.map(stripColorTags).join(" | ")} |\n`; } const out = cells.map((c) => renderInline(marked.lexer(c).flatMap(flattenInline)).replace( @@ -170,7 +170,7 @@ export function mdKvTable( // so that backslash-pipe sequences in values don't produce escaped pipes in // the rendered output. Newlines are collapsed to spaces. lines.push( - `| **${label}** | ${value.replace(/\n/g, " ").replace(/\\/g, "\\\\").replace(/\|/g, "\u2502")} |` + `| **${label}** | ${value.replace(/\n/g, " ").replace(/\\/g, "\\\\").replace(//g, ">").replace(/\|/g, "\u2502")} |` ); } return lines.join("\n"); @@ -231,7 +231,7 @@ const RE_SELF_TAG = /^<([a-z]+)>([\s\S]*?)<\/\1>$/i; * Used in plain output mode so that `colorTag()` values don't leak as literal * HTML-like tags into piped / CI / redirected output. */ -function stripColorTags(text: string): string { +export function stripColorTags(text: string): string { // Repeatedly replace all supported color tag pairs until none remain. // The loop handles nested tags (uncommon but possible). const tagPattern = Object.keys(COLOR_TAGS).join("|"); diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index 69ed89f6..fb76fef2 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -14,6 +14,7 @@ import { escapeMarkdownCell, isPlainOutput, renderInlineMarkdown, + stripColorTags, } from "./markdown.js"; import { type Alignment, renderTextTable } from "./text-table.js"; @@ -54,7 +55,7 @@ export function buildMarkdownTable( const rows = items .map( (item) => - `| ${columns.map((c) => escapeMarkdownCell(c.value(item))).join(" | ")} |` + `| ${columns.map((c) => escapeMarkdownCell(stripColorTags(c.value(item)))).join(" | ")} |` ) .join("\n"); return `${header}\n${separator}\n${rows}`; From 6afd84b1f7ccb98d2f68057c7a93647580539b2e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 17:22:22 +0000 Subject: [PATCH 40/52] chore: remove duplicate Long-term Knowledge section from AGENTS.md --- AGENTS.md | 137 ------------------------------------------------------ 1 file changed, 137 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de43c75d..fb92cb28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -709,140 +709,3 @@ mock.module("./some-module", () => ({ * **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. - - -## Long-term Knowledge - -### Architecture - - -* **@sentry/node-core pulls in @apm-js-collab/code-transformer (3.3MB) but it's tree-shaken**: The dependency chain \`@sentry/node\` → \`@sentry/node-core\` → \`@apm-js-collab/tracing-hooks\` → \`@apm-js-collab/code-transformer\` adds a 3.3MB input file to the esbuild bundle analysis. However, esbuild fully tree-shakes it out — it contributes 0 bytes to the final npm bundle output. This was verified by checking both the released 0.13.0 bundle and the current build: neither contains any \`code-transformer\` or \`apm-js-collab\` strings. Don't be alarmed by its presence in metafile inputs. - -* **GitHub Packages has no generic/raw file registry — only typed registries**: GitHub Packages only supports typed registries (Container/npm/Maven/NuGet/RubyGems) — there is no generic file store where you can upload a binary and get a download URL. For distributing arbitrary binaries: the Container registry (ghcr.io) via ORAS works but requires a 3-step download (token → manifest → blob). The npm registry at \`npm.pkg.github.com\` requires authentication even for public packages, making it unsuitable for install scripts. This means for binary distribution on GitHub without GitHub Releases, GHCR+ORAS is the only viable GitHub Packages option. - -* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: The \`cli.sentry.dev\` domain points to GitHub Pages for the getsentry/cli repo, confirmed by the CNAME file on the gh-pages branch containing \`cli.sentry.dev\`. The branch contains the Astro/Starlight docs site output plus the install script at \`/install\`. Craft's gh-pages target manages this branch, wiping and replacing all content on each stable release. The docs are built as a zip artifact named \`gh-pages.zip\` (matched by Craft's \`DEFAULT\_DEPLOY\_ARCHIVE\_REGEX: /^(?:.+-)?gh-pages\\.zip$/\`). - -* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The Sentry CLI npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses Node.js 22's built-in \`node:sqlite\` module. The \`require('node:sqlite')\` happens during module loading (via esbuild's inject option) — before any user code runs. package.json declares \`engines: { node: '>=22' }\` but pnpm/npm don't enforce this by default. A runtime version guard in the esbuild banner catches this early with a clear error message pointing users to either upgrade Node.js or use the standalone binary (\`curl -fsSL https://cli.sentry.dev/install | bash\`). - -* **parseSentryUrl does not handle subdomain-style SaaS URLs**: The URL parser in src/lib/sentry-url-parser.ts handles both path-based (\`/organizations/{org}/...\`) and subdomain-style (\`https://{org}.sentry.io/issues/123/\`) SaaS URLs. The \`matchSubdomainOrg()\` function extracts the org from the hostname when it ends with \`.sentry.io\`, supports \`/issues/{id}/\`, \`/issues/{id}/events/{eventId}/\`, and \`/traces/{traceId}/\` paths. Region subdomains (\`us\`, \`de\`) are filtered out by requiring org slugs to be longer than 2 characters. Confirmed against getsentry/sentry codebase: the subdomain IS the org slug directly (e.g., \`my-org.sentry.io\`), NOT a fixed prefix like \`o.sentry.io\`. The Sentry backend builds permalinks via \`organization.absolute\_url()\` → \`generate\_organization\_url(slug)\` using the \`system.organization-base-hostname\` template \`{slug}.sentry.io\` (src/sentry/organizations/absolute\_url.py:72-92). When customer domains are enabled (production SaaS), \`customer\_domain\_path()\` strips \`/organizations/{slug}/\` from paths. Region subdomains are filtered by Sentry's \`subdomain\_is\_region()\`, aligning with the \`org.length <= 2\` check. Self-hosted uses path-based: \`/organizations/{org\_slug}/issues/{id}/\`. - -* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution now uses a multi-step approach in \`resolveNumericIssue()\` (extracted from \`resolveIssue\` to reduce cognitive complexity). Resolution order: (1) \`resolveOrg({ cwd })\` tries DSN/env/config for org context, (2) if org found, uses \`getIssueInOrg(org, id)\` with region routing, (3) if no org, falls back to unscoped \`getIssue(id)\`, (4) extracts org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. The \`explicit-org-numeric\` case now uses \`getIssueInOrg(parsed.org, id)\` instead of the unscoped endpoint. \`getIssueInOrg\` was added to api-client.ts using the SDK's \`retrieveAnIssue\` with the standard \`getOrgSdkConfig + unwrapResult\` pattern. The \`resolveOrgAndIssueId\` wrapper (used by \`explain\`/\`plan\`) no longer throws "Organization is required" for bare numeric IDs when the permalink contains the org slug. - -* **Install script nightly channel support**: The curl install script (\`cli.sentry.dev/install\`) supports \`--channel nightly\`. For nightly: downloads the binary directly from the \`nightly\` release tag (no version.json fetch needed — just uses \`nightly\` as both download tag and display string). Passes \`--channel nightly\` to \`$binary cli setup --install --method curl --channel nightly\` so the channel is persisted in DB. Usage: \`curl -fsSL https://cli.sentry.dev/install | bash -s -- --channel nightly\`. The install script does NOT fetch version.json — that's only used by the upgrade/version-check flow to compare versions. - -### Decision - - -* **Nightly release: delete-then-upload instead of clobber for asset management**: The publish-nightly CI job deletes ALL existing release assets before uploading new ones, rather than using \`gh release upload --clobber\`. This ensures that if asset names change (e.g., removing a platform or renaming files), stale assets don't linger on the nightly release indefinitely. Pattern: \`gh release view nightly --json assets --jq '.assets\[].name' | while read -r name; do gh release delete-asset nightly "$name" --yes; done\` followed by \`gh release upload nightly \\` (no --clobber needed since assets were cleared). - -* **Raw markdown output for non-interactive terminals, rendered for TTY**: Design decision for getsentry/cli: output raw CommonMark markdown when stdout is not interactive (piped, redirected, CI), and only render through marked-terminal when a human is looking at it in a TTY. Detection: \`const isInteractive = process.stdout.isTTY\` — no \`process.env.CI\` check needed, since if a CI runner allocates a pseudo-TTY it can render the styled output fine. Override env vars with precedence: \`SENTRY\_PLAIN\_OUTPUT\` (most specific) > \`NO\_COLOR\` > auto-detect via \`isTTY\`. \`SENTRY\_PLAIN\_OUTPUT=1\` forces raw markdown even on TTY; \`SENTRY\_PLAIN\_OUTPUT=0\` forces rendered even when piped. \`NO\_COLOR\` (no-color.org standard) also triggers plain mode since rendered output is ANSI-colored. Chalk auto-disables colors when piped, so pre-embedded ANSI codes become plain text in raw mode. Output modes: \`--json\` → JSON (unchanged), TTY → rendered markdown via marked-terminal, non-TTY → raw CommonMark. For streaming formatters (log/trace row-by-row output), TTY keeps current ANSI-colored padded text for efficient incremental display, while non-TTY emits markdown table rows with header+separator on first write, producing valid markdown files when redirected. - -### Gotcha - - -* **React useState async pitfall**: React useState setter is async — reading state immediately after setState returns stale value in dashboard components - -* **TypeScript strict mode caveat**: TypeScript strict null checks require explicit undefined handling - -* **git notes are lost on commit amend — must re-attach to new SHA**: Git notes are attached to a specific commit SHA. When you \`git commit --amend\`, the old commit is replaced with a new one (different SHA), and the note attached to the old SHA becomes orphaned. After amending, you must re-add the note to the new commit with \`git notes add\` targeting the new SHA. This also affects \`git push --force\` of notes refs — the remote note ref still points to the old SHA. - -* **pnpm overrides with version-range keys don't force upgrades of already-compatible resolutions**: pnpm overrides with version-range selectors like \`"minimatch@>=10.0.0 <10.2.1": ">=10.2.1"\` do NOT work as expected for forcing upgrades of transitive deps that already satisfy their parent's semver range. If a parent requests \`^10.1.1\` and pnpm resolves \`10.1.1\`, the override key \`>=10.0.0 <10.2.1\` should match but doesn't reliably force re-resolution — even with \`pnpm install --force\`. The workaround is a blanket override without a version selector: \`"minimatch": ">=10.2.1"\`. This is only safe when ALL consumers are on the same major version line (otherwise it's a breaking change). Verify first with \`pnpm why \\` that no other major versions exist in the tree before using a blanket override. - -* **pnpm overrides with >= can cross major versions — use ^ to constrain**: When using pnpm overrides to patch a transitive dependency vulnerability, \`"ajv@<6.14.0": ">=6.14.0"\` will resolve to the latest ajv (v8.x), not the latest 6.x. ajv v6 and v8 have incompatible APIs — this broke eslint (\`@eslint/eslintrc\` calls \`ajv\` v6 API, crashes with \`Cannot set properties of undefined (setting 'defaultMeta')\` on v8). Fix: use \`"ajv@<6.14.0": "^6.14.0"\` to constrain within the same major. This applies to any override where the target package has multiple major versions in the registry — always use \`^\` (or \`~\`) instead of \`>=\` to stay within the compatible major line. - -* **marked-terminal unconditionally imports cli-highlight and node-emoji — no tree-shaking possible**: marked-terminal has static top-level imports of \`cli-highlight\` (which pulls in highlight.js, ~570KB minified output) and \`node-emoji\` (which pulls in emojilib, ~208KB minified output) at lines 5-6 of its index.js. These are unconditional — there's no config option to disable them, and the \`emoji: false\` option only skips the emoji replacement function but doesn't prevent the import. esbuild cannot tree-shake static imports. This means any bundle including marked-terminal will grow by ~970KB (highlight.js + emojilib + parse5). To avoid this in a CLI bundle, you'd need to either: (1) write a custom marked renderer using only chalk, (2) fork marked-terminal with dynamic imports, or (3) use esbuild's \`external\` option (but then those packages must be available at runtime). - -* **pnpm-lock.yaml merge conflicts: regenerate don't manually merge**: When \`pnpm-lock.yaml\` has merge conflicts, never try to manually resolve the conflict markers. Instead: (1) \`git checkout --theirs pnpm-lock.yaml\` (or \`--ours\` depending on which package.json changes you want as base), (2) run \`pnpm install\` to regenerate the lockfile incorporating both sides' \`package.json\` changes (including overrides). This produces a clean lockfile that reflects the merged dependency state. Manual conflict resolution in lockfiles is error-prone and unnecessary since pnpm can regenerate it deterministically. - -* **git stash pop after merge can cause second conflict on same file**: When you stash local changes, merge, then \`git stash pop\`, the stash apply can create a NEW conflict on the same file that was just conflict-resolved in the merge. This happens because the stash was based on the pre-merge state and conflicts with the post-merge-resolution content. The resolution requires a second manual conflict resolution pass. To avoid: if stashed changes are lore/auto-generated content, consider just dropping the stash and re-running the generation tool after merge instead of popping. - -* **pnpm overrides become stale when dependency tree changes — audit with pnpm why**: pnpm overrides can become orphaned when the dependency tree changes. For example, removing a package that was the sole consumer of a transitive dep (like removing \`@sentry/typescript\` which pulled in \`tslint\` which was the only consumer of \`diff\`), or upgrading a package that switches to a differently-named dependency (like \`minimatch@10.2.4\` switching from \`@isaacs/brace-expansion\` to the unscoped \`brace-expansion\`). Orphaned overrides sit silently in package.json and could unexpectedly constrain versions if a future dependency reintroduces the package name. After removing packages or upgrading dependencies that change the transitive tree, audit overrides with \`pnpm why \\` to verify each override still has consumers in the resolved tree. Remove any that return empty results. - -* **ESM modules prevent vi.spyOn of child\_process.spawnSync — use test subclass pattern**: In Vitest with ESM, you cannot spy on exports from Node built-in modules like \`child\_process.spawnSync\` — it throws \`Cannot spy on export. Module namespace is not configurable in ESM\`. The workaround for testing code that calls \`spawnSync\` (like npm version checks) is to create a test subclass that overrides the method calling \`spawnSync\` and injects controllable values. Example: \`TestNpmTarget\` overrides \`checkRequirements()\` to set \`this.npmVersion\` from a static \`mockVersion\` field instead of calling \`spawnSync(npm, \['--version'])\`. This avoids the ESM limitation while keeping test isolation. \`vi.mock\` at module level is another option but affects all tests in the file. - -* **npm OIDC only works for publish — npm info/view still needs traditional auth**: Per npm docs: "OIDC authentication is currently limited to the publish operation. Other npm commands such as install, view, or access still require traditional authentication methods." This means \`npm info \ version\` (used by Craft's \`getLatestVersion()\`) cannot use OIDC tokens. For public packages this is fine — \`npm info\` works without auth. For private packages, a read-only \`NPM\_TOKEN\` is still needed alongside OIDC. Craft handles this by: using token auth for \`getLatestVersion()\` when a token is available, running unauthenticated otherwise, and warning + skipping version checks for private packages in OIDC mode without a token. - -* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` - -* **marked and marked-terminal must be devDependencies in bundled CLI projects**: In the getsentry/cli project, all npm packages used at runtime must be bundled by the build step (esbuild). Packages like \`marked\` and \`marked-terminal\` belong in \`devDependencies\`, not \`dependencies\`. The CI \`check:deps\` step enforces this — anything in \`dependencies\` that isn't a true runtime requirement (native addon, bin entry) will fail the check. This applies to all packages consumed only through the bundle. - -* **CodeQL flags incomplete markdown cell escaping — must escape backslash before pipe**: When escaping user content for markdown table cells, replacing only \`|\` with \`\\|\` triggers CodeQL's "Incomplete string escaping or encoding" alert (high severity). The fix is to escape backslashes first, then pipes: \`str.replace(/\\\\/g, "\\\\\\\\" ).replace(/\\|/g, "\\\\|")\`. In getsentry/cli this is centralized as \`escapeMarkdownCell()\` in \`src/lib/formatters/markdown.ts\`. All formatters that build markdown table rows (human.ts, log.ts, trace.ts) must use this helper instead of inline \`.replace()\` calls. - -* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: In getsentry/cli tests, \`mockFetch()\` sets \`globalThis.fetch\` to a new function. Calling it twice replaces the first mock entirely. A common bug: calling \`mockBinaryDownload()\` then \`mockGitHubVersion()\` means the binary download URL hits the version mock (returns 404). Fix: create a single unified fetch mock that handles ALL endpoints the test needs (version API, binary download, npm registry, version.json). Pattern: \`\`\`typescript mockFetch(async (url) => { const urlStr = String(url); if (urlStr.includes('releases/latest')) return versionResponse; if (urlStr.includes('version.json')) return nightlyResponse; return new Response(gzipped, { status: 200 }); // binary download }); \`\`\` This caused multiple test failures when trying to test the full upgrade→download→setup pipeline. - -* **marked-terminal@7 peer dependency requires marked < 16**: \`marked-terminal@7.3.0\` declares a peer dependency of \`marked@>=1 <16\`. Installing \`marked@17\` triggers a peer dependency warning and may cause runtime issues. Use \`marked@^15\` (e.g., \`marked@15.0.12\`) for compatibility. In Bun, peer dependency warnings don't block installation but the version mismatch can cause subtle breakage. - -* **ghcr.io blob download: curl -L breaks because auth header leaks to Azure redirect**: When downloading blobs from ghcr.io via raw HTTP, the registry returns a 307 redirect to a signed Azure Blob Storage URL (\`pkg-containers.githubusercontent.com\`). Using \`curl -L\` with the \`Authorization: Bearer \\` header fails with 404 because curl forwards the auth header to Azure, which rejects it. Fix: don't use \`-L\`. Instead, extract the redirect URL and follow it separately: \`\`\`bash REDIR=$(curl -s -w '\n%{redirect\_url}' -o /dev/null -H "Authorization: Bearer $TOKEN" "$BLOB\_URL" | tail -1) curl -sf "$REDIR" -o output.file \`\`\` This affects any tool using raw HTTP to pull from ghcr.io — the ORAS CLI handles this internally but install scripts using plain curl need the two-step approach. - -* **GHCR packages created as private by default — must manually make public once**: When first pushing to \`ghcr.io/getsentry/cli\` via ORAS or Docker, the container package is created with \`visibility: private\`. Anonymous pulls fail until the package is made public. This is a one-time manual step via GitHub UI at the package settings page (Danger Zone → Change visibility → Public). The GitHub Packages API for changing visibility requires org admin scopes that aren't available via \`gh auth\` by default. For the getsentry/cli nightly distribution, the package has already been made public. - -* **Craft gh-pages target wipes entire branch on publish**: Craft's \`GhPagesTarget.commitArchiveToBranch()\` runs \`git rm -r -f .\` before extracting the docs archive, deleting ALL existing files on the gh-pages branch. Any additional files placed there (e.g., nightly binaries in a \`nightly/\` directory) would be wiped on every stable release. The \`cli.sentry.dev\` site is served from the gh-pages branch (CNAME file confirms this). If using gh-pages for hosting non-docs files, either accept the brief outage window until the next main push re-adds them, add a \`postReleaseCommand\` in \`.craft.yml\` to restore the files, or use a different hosting mechanism entirely. - -* **upload-artifact strips directory prefix from glob paths**: When \`actions/upload-artifact@v4\` uploads with \`path: dist-bin/sentry-\*\`, it strips the \`dist-bin/\` directory prefix — the artifact stores files as \`sentry-linux-x64\`, \`sentry-linux-x64.gz\`, etc. at the root level. When \`actions/download-artifact@v4\` with \`merge-multiple: true\` and \`path: artifacts\` downloads these, files end up at \`artifacts/sentry-\*.gz\`, NOT \`artifacts/dist-bin/sentry-\*.gz\`. This caused the publish-nightly job to fail with \`no matches found for artifacts/dist-bin/\*.gz\`. The fix was changing the glob to \`artifacts/\*.gz\`. - -* **GitHub immutable releases prevent rolling nightly tag pattern**: The getsentry/cli repo has immutable releases enabled (org/repo setting, will NOT be turned off). This means: (1) once a release is published, its assets cannot be modified or deleted, (2) a tag used by a published release can NEVER be reused, even after deleting the release — \`gh release delete nightly --cleanup-tag\` followed by \`gh release create nightly\` fails with \`tag\_name was used by an immutable release\`. Draft releases ARE mutable but use unpredictable \`/download/untagged-xxx/\` URLs instead of tag-based URLs, and publishing a draft with a previously-used tag also fails. This breaks the original nightly design of a single rolling \`nightly\` tag. The \`nightly\` tag is now permanently poisoned. New approach needed: per-version release tags (e.g., \`0.13.0-dev.1772062077\`) with API-based discovery of the latest prerelease. - -* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: In getsentry/cli tests, \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` inside the repo tree. When code calls \`detectDsn(cwd)\` with this temp dir as cwd (e.g., via \`resolveOrg({ cwd })\`), \`findProjectRoot\` walks up from \`.test-tmp/prefix-xxx\` and finds the repo's \`.git\` directory, causing DSN detection to scan the actual source code for Sentry DSNs. This can trigger network calls that hit test fetch mocks (returning 404s or unexpected responses), leading to 5-second test timeouts. Fix: always use \`useTestConfigDir(prefix, { isolateProjectRoot: true })\` when the test exercises any code path that might call \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\` with the config dir as cwd. The \`isolateProjectRoot\` option creates a \`.git\` directory inside the temp dir, stopping the upward walk immediately. - -* **mock.module bleeds across Bun test files — never include test/isolated/ in test:unit**: In Bun's test runner, \`mock.module()\` pollutes the shared module registry for ALL subsequently-loaded test files in the same \`bun test\` invocation. Including \`test/isolated/\` in \`test:unit\` caused 132 failures because the \`node:child\_process\` mock leaked into DB tests, config tests, and others that transitively import child\_process. The \`test:isolated\` script MUST remain separate from \`test:unit\`. This also means isolated test coverage does NOT appear in Codecov PR patch coverage — only \`test:unit\` feeds lcov. Accept that code paths requiring \`mock.module\` (e.g., \`node:child\_process spawn\` wrappers like \`runCommand\`, \`isInstalledWith\`, \`executeUpgradeHomebrew\`, \`executeUpgradePackageManager\`) will have zero Codecov coverage. - -* **GitHub Actions: use deterministic timestamps across jobs, not Date.now()**: When multiple GitHub Actions jobs need to agree on a timestamp-based version string (e.g., nightly builds where \`build-binary\` bakes the version in and \`publish-nightly\` creates version.json), never use \`Date.now()\` or \`Math.floor(Date.now()/1000)\` independently in each job. Jobs run at different times, producing different timestamps. Instead, derive the timestamp from a shared deterministic source like \`github.event.head\_commit.timestamp\` (the commit time). Convert to Unix seconds: \`date -d '\' +%s\`. This ensures all matrix entries and downstream jobs produce the same version string. - -* **version-check.test.ts has pre-existing unmocked fetch calls**: In getsentry/cli, \`test/lib/version-check.test.ts\` makes unmocked fetch calls to \`https://api.github.com/repos/getsentry/cli/releases/latest\` and to Sentry's ingest endpoint. These are pre-existing issues that produce \`\[TEST] Unexpected fetch call\` warnings in test output but don't cause test failures. Not related to any specific PR — existed before the Homebrew changes. - -* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: The @sentry/api SDK creates a \`Request\` object with Content-Type set in its headers, then calls \`\_fetch(request2)\` with only one argument (no init). In sentry-client.ts's \`authenticatedFetch\`, \`init\` is undefined, so \`prepareHeaders(init, token)\` creates empty headers from \`new Headers(undefined)\`. When \`fetch(Request, {headers})\` is called, Node.js strictly follows the spec where init headers replace Request headers entirely — stripping Content-Type. This causes HTTP 415 'Unsupported media type' errors on POST requests (e.g., startSeerIssueFix). Bun may merge headers instead, so the bug only manifests under Node.js runtime. Fix: \`prepareHeaders\` must accept the input parameter and fall back to \`input.headers\` when \`init\` is undefined. Also fix method extraction: \`init?.method\` returns undefined for Request-only calls, defaulting to 'GET' even for POST requests. - -* **Several commands bypass telemetry by importing buildCommand from @stricli/core directly**: src/lib/command.ts wraps Stricli's buildCommand to auto-capture flag/arg telemetry via Sentry. But trace/list, trace/view, log/view, api.ts, and help.ts import buildCommand directly from @stricli/core, silently skipping telemetry. Fix: change their imports to use ../../lib/command.js. Consider adding a Biome lint rule (noRestrictedImports equivalent) to prevent future regressions. - -* **@sentry/api SDK issues filed to sentry-api-schema repo**: All @sentry/api SDK issues should be filed to https://github.com/getsentry/sentry-api-schema/ and assigned to @MathurAditya724. Two known issues: (1) unwrapResult() discards Link response headers, silently truncating listTeams/listRepositories at 100 items and preventing cursor pagination. (2) No paginated variants exist for team/repo/issue list endpoints, forcing callers to bypass the SDK with raw requests. - -* **Esbuild banner template literal double-escape for newlines**: When using esbuild's \`banner\` option with a TypeScript template literal containing string literals that need \`\n\` escape sequences: use \`\\\\\\\n\` in the TS source. The chain is: TS template literal \`\\\\\\\n\` → esbuild banner output \`\\\n\` → JS runtime interprets as newline. Using only \`\\\n\` in the TS source produces a literal newline character inside a JS double-quoted string, which is a SyntaxError. This applies to any esbuild banner/footer that injects JS strings containing escape sequences. Discovered in script/bundle.ts for the Node.js version guard error message. - -* **Craft minVersion >= 2.21.0 silently disables custom bump-version.sh**: When \`minVersion\` in \`.craft.yml\` is >= 2.21.0 and no \`preReleaseCommand\` is defined, Craft switches from running the default \`bash scripts/bump-version.sh\` to automatic version bumping based on configured publish targets. If the only target is \`github\` (which doesn't support auto-bump — only npm, pypi, crates, gem, pub-dev, hex, nuget do), the version bump silently does nothing. The release gets tagged with unbumped files. Fix: explicitly set \`preReleaseCommand: bash scripts/bump-version.sh\` in \`.craft.yml\` when using a custom bump script with targets that don't support auto-bump. This caused the 26.2.0 release to ship with \`:nightly\` image tags. - -* **Streaming formatters can't use marked-terminal incrementally — tables need all rows**: Markdown tables rendered through marked-terminal (via cli-table3) require all rows upfront to compute column widths. You cannot incrementally render one row at a time through \`renderMarkdown()\`. For streaming/polling formatters like \`formatLogRow\`, \`formatTraceRow\`, \`formatLogsHeader\` that write row-by-row, two approaches: (1) TTY mode: keep current padded text with ANSI colors for efficient incremental display, (2) non-TTY mode: emit raw markdown table row syntax (\`| col | col |\`) which is independently readable without width calculation. Individual cell \*values\* can still be styled via \`renderInlineMarkdown()\` (using \`marked.parseInline()\`) — this renders inline markdown like \`\*\*bold\*\*\`, \`\` \`code\` \`\`, and \`\*italic\*\` without block-level wrapping. This lets streaming rows benefit from markdown formatting per-cell without needing the full table pipeline. This means streaming formatters bypass \`renderMarkdown()\` by design but can use the inline variant. - -### Pattern - - -* **Kubernetes deployment pattern**: Use helm charts for Kubernetes deployments with resource limits - -* **CLI UX: auto-correct common user mistakes with stderr warnings instead of hard errors**: When a CLI command can unambiguously detect a common user mistake (like using the wrong separator character), prefer auto-correcting the input and printing a warning to stderr over throwing a hard error. This is safe when: (1) the input is already invalid and would fail anyway, (2) there's no ambiguity in the correction, and (3) the warning goes to stderr so it doesn't interfere with JSON/stdout output. Implementation pattern: normalize inputs at the command level before passing to pure parsing functions, keeping the parsers side-effect-free. The \`gh\` CLI (GitHub CLI) is the UX model — match its conventions. - -* **esbuild metafile output bytes vs input bytes — use output for real size impact**: When analyzing bundle size with esbuild's metafile, \`result.metafile.inputs\` shows raw source file sizes BEFORE minification and tree-shaking — these are misleading for size impact analysis. A 3.3MB input file may contribute 0 bytes to output if tree-shaken. Use \`result.metafile.outputs\[outfile].inputs\` to see actual per-file output contribution after minification. To dump metafile: add \`import { writeFileSync } from 'node:fs'; writeFileSync('/tmp/meta.json', JSON.stringify(result.metafile));\` after the build call, then analyze with \`jq\`. The bundle script at script/bundle.ts generates metafile but doesn't write it to disk by default. - -* **Bun.spawn is writable — use direct assignment for test spying instead of mock.module**: Unlike \`node:child\_process\` imports (which require \`mock.module\` and isolated test files), \`Bun.spawn\` is a writable property on the global \`Bun\` object. Tests can replace it directly in \`beforeEach\`/\`afterEach\` without module-level mocking: \`\`\`typescript let originalSpawn: typeof Bun.spawn; beforeEach(() => { originalSpawn = Bun.spawn; Bun.spawn = ((cmd, \_opts) => ({ exited: Promise.resolve(0), })) as typeof Bun.spawn; }); afterEach(() => { Bun.spawn = originalSpawn; }); \`\`\` This avoids the mock.module bleed problem entirely and works in regular \`test:unit\` files (counts toward Codecov). Used successfully in \`test/commands/cli/upgrade.test.ts\` to test \`runSetupOnNewBinary\` and \`migrateToStandaloneForNightly\` which spawn child processes via \`Bun.spawn\`. - -* **ANSI codes survive marked-terminal + cli-table3 pipeline in table cells**: Pre-rendered ANSI escape codes (e.g., from chalk) embedded in markdown table cell values survive the \`marked\` → \`marked-terminal\` → \`cli-table3\` rendering pipeline. cli-table3 uses \`string-width\` which correctly treats ANSI codes as zero-width for column width calculation. This means you can pre-color individual cell values with chalk/ANSI before embedding them in a markdown table string, and the colors will render correctly in the terminal. Verified experimentally in getsentry/cli with \`marked@15\` + \`marked-terminal@7.3.0\` running under Bun. This enables per-cell semantic coloring (e.g., red for errors, green for resolved) in markdown-rendered tables without needing markdown color syntax (which doesn't exist). - -* **Formatter return type migration: string\[] to string for markdown rendering**: The formatter functions (\`formatLogDetails\`, \`formatTraceSummary\`, \`formatOrgDetails\`, \`formatProjectDetails\`, \`formatIssueDetails\`) were migrated from returning \`string\[]\` (array of lines) to returning \`string\` (rendered markdown). When updating tests for this migration: (1) remove \`.join("\n")\` calls, (2) replace \`.map(stripAnsi)\` with \`stripAnsi(result)\`, (3) replace \`Array.isArray(result)\` checks with \`typeof result === "string"\`, (4) replace line-by-line exact match tests (\`lines\[0] === "..."\`, \`lines.some(l => l.includes(...))\`) with content-based checks (\`result.includes(...)\`) since markdown tables render with Unicode box-drawing characters, not padded text columns. The \`writeTable()\` function also changed from text-padded columns to markdown table rendering. - -* **Markdown table structure for marked-terminal: blank header row + separator + data rows**: When building markdown tables for \`marked-terminal\` rendering in this codebase, the pattern is: blank header row (\`| | |\`), then separator (\`|---|---|\`), then data rows (\`| \*\*Label\*\* | value |\`). Putting data rows before the separator produces malformed tables where cell values don't render. This was discovered when the SDK section in \`log.ts\` had the data row before the separator, causing the SDK name to not appear in output. All key-value detail sections (Context, SDK, Trace, Source Location, OpenTelemetry) in \`formatLogDetails\`, \`formatOrgDetails\`, \`formatProjectDetails\`, \`formatIssueDetails\`, and \`formatTraceSummary\` use this pattern. - -* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts follow this pattern: (1) call \`getOrgSdkConfig(orgSlug)\` which resolves the org's regional URL and returns an SDK client config, (2) spread config into the SDK function call: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass result to \`unwrapResult(result, errorContext)\`. There are 14+ usages of this pattern. The \`getOrgSdkConfig\` function (line ~167) calls \`resolveOrgRegion(orgSlug)\` then \`getSdkConfig(regionUrl)\`. Follow this exact pattern when adding new org-scoped endpoints like \`getIssueInOrg\`. - -* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as required or advisory checks. Both typically take 2-3 minutes. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \ --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`. - -* **Isolated test files for mock.module in Bun tests**: In the getsentry/cli repo, tests that use Bun's \`mock.module()\` must be placed in \`test/isolated/\` as separate test files AND run via the separate \`test:isolated\` script (not \`test:unit\`). \`mock.module\` affects the entire module registry and bleeds into ALL subsequently-loaded test files in the same \`bun test\` invocation. Attempting to include \`test/isolated/\` in \`test:unit\` caused 132 test failures from \`node:child\_process\` mock pollution. Consequence: isolated test coverage does NOT appear in Codecov PR patch metrics. For code using \`Bun.spawn\` (not \`node:child\_process\`), prefer direct property assignment (\`Bun.spawn = mockFn\`) in regular test files instead — \`Bun.spawn\` is writable and doesn't require mock.module. - -* **GitHub Actions: skipped needs jobs don't block downstream**: In GitHub Actions, if a job in \`needs\` is skipped due to its \`if\` condition evaluating to false, downstream jobs that depend on it still run (skipped ≠ failed). Output values from the skipped job are empty strings. This enables a pattern where a job like \`nightly-version\` has \`if: github.ref == 'refs/heads/main'\` and downstream jobs like \`build-binary\` list it in \`needs\` — on PRs the nightly job is skipped, outputs are empty, and conditional steps using \`if: needs.nightly-version.outputs.version != ''\` are safely skipped. No need for complex conditional \`needs\` expressions. - -* **OpenCode worktree blocks checkout of main — use stash + new branch instead**: When working in the getsentry/cli repo, \`git checkout main\` fails because main is used by an OpenCode worktree at \`~/.local/share/opencode/worktree/\`. Workaround: stash changes, fetch, create a new branch from origin/main (\`git stash && git fetch && git checkout -b \ origin/main && git stash pop\`), then pop stash. Do NOT try to checkout main directly. This also applies when rebasing onto latest main — you must use \`origin/main\` as the target, never the local \`main\` branch. - -### Preference - - -* **General coding preference**: Prefer explicit error handling over silent failures - -* **Code style**: User prefers no backwards-compat shims, fix callers directly - -* **Code style**: User prefers no backwards-compat shims, fix callers directly - -* **General coding preference**: Prefer explicit error handling over silent failures - -* **.opencode directory should be gitignored**: The \`.opencode/\` directory (used by OpenCode for plans, session data, etc.) should be in \`.gitignore\` and never committed. It was added to \`.gitignore\` alongside \`.idea\` and \`.DS\_Store\` in the editor/tool ignore section. - -* **CI: define shared env vars at workflow level, not per-job**: Reviewer (BYK) prefers defining environment variables that are used by multiple jobs at the workflow-level \`env:\` block rather than repeating them in each job's step-level \`env:\`. GitHub Actions workflow-level \`env\` is inherited by all jobs and steps. Example: \`COMMIT\_TIMESTAMP\` was moved from being defined in both \`build-binary\` and \`publish-nightly\` to a single workflow-level declaration. - -* **CI scripts: prefer jq/sed over node -e for JSON manipulation**: Reviewer (BYK) prefers using standard Unix tools (\`jq\`, \`sed\`, \`awk\`) over \`node -e\` for simple JSON manipulation in CI workflow scripts. For example, reading/modifying package.json version: \`jq -r .version package.json\` to read, \`jq --arg v "$NEW" '.version = $v' package.json > tmp && mv tmp package.json\` to write. This avoids requiring Node.js to be installed in CI steps that only need basic JSON operations, and is more readable for shell-centric workflows. - From 6f6fc17383e58ad604bf9bbdf5b80985df510ccf Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 17:33:29 +0000 Subject: [PATCH 41/52] refactor(formatters): DRY up manual markdown table building with mdKvTable/mdRow Migrate 11 manual markdown table locations to use shared helpers: - 6 KV tables in human.ts + 1 in trace.ts now use mdKvTable() - 3 data table rows in human.ts, trace.ts, log.ts now use mdRow() - 1 data table header in human.ts now uses mdTableHeader() Simplify mdKvTable() escaping: only replace pipes and newlines (structural safety). Content escaping is the caller's responsibility, which allows values with intentional markdown (colorTag, safeCodeSpan, backtick spans) to pass through unmangled. --- src/lib/formatters/human.ts | 209 +++++++++++++++------------------ src/lib/formatters/log.ts | 2 +- src/lib/formatters/markdown.ts | 4 +- src/lib/formatters/trace.ts | 23 ++-- 4 files changed, 106 insertions(+), 132 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 4942e25b..d974b63d 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -30,6 +30,9 @@ import { colorTag, escapeMarkdownCell, escapeMarkdownInline, + mdKvTable, + mdRow, + mdTableHeader, renderMarkdown, safeCodeSpan, } from "./markdown.js"; @@ -497,14 +500,15 @@ export function formatIssueDetails(issue: SentryIssue): string { lines.push(""); // Key-value details as a table - const rows: string[] = []; + const kvRows: [string, string][] = []; - rows.push( - `| **Status** | ${formatStatusLabel(issue.status)}${issue.substatus ? ` (${capitalize(issue.substatus)})` : ""} |` - ); + kvRows.push([ + "Status", + `${formatStatusLabel(issue.status)}${issue.substatus ? ` (${capitalize(issue.substatus)})` : ""}`, + ]); if (issue.priority) { - rows.push(`| **Priority** | ${capitalize(issue.priority)} |`); + kvRows.push(["Priority", capitalize(issue.priority)]); } if ( @@ -513,75 +517,68 @@ export function formatIssueDetails(issue: SentryIssue): string { ) { const tier = getSeerFixabilityLabel(issue.seerFixabilityScore); const fixDetail = formatFixabilityDetail(issue.seerFixabilityScore); - rows.push( - `| **Fixability** | ${colorTag(FIXABILITY_TAGS[tier], fixDetail)} |` - ); + kvRows.push(["Fixability", colorTag(FIXABILITY_TAGS[tier], fixDetail)]); } let levelLine = issue.level ?? "unknown"; if (issue.isUnhandled) { levelLine += " (unhandled)"; } - rows.push(`| **Level** | ${levelLine} |`); - rows.push( - `| **Platform** | ${escapeMarkdownCell(issue.platform ?? "unknown")} |` - ); - rows.push(`| **Type** | ${escapeMarkdownCell(issue.type ?? "unknown")} |`); - rows.push( - `| **Assignee** | ${escapeMarkdownCell(String(issue.assignedTo?.name ?? "Unassigned"))} |` - ); + kvRows.push(["Level", levelLine]); + kvRows.push(["Platform", issue.platform ?? "unknown"]); + kvRows.push(["Type", issue.type ?? "unknown"]); + kvRows.push(["Assignee", String(issue.assignedTo?.name ?? "Unassigned")]); if (issue.project) { - rows.push( - `| **Project** | ${escapeMarkdownCell(issue.project.name ?? "(unknown)")} (${safeCodeSpan(issue.project.slug ?? "")}) |` - ); + kvRows.push([ + "Project", + `${issue.project.name ?? "(unknown)"} (${safeCodeSpan(issue.project.slug ?? "")})`, + ]); } const firstReleaseVersion = issue.firstRelease?.shortVersion; const lastReleaseVersion = issue.lastRelease?.shortVersion; if (firstReleaseVersion || lastReleaseVersion) { - const first = escapeMarkdownCell(String(firstReleaseVersion ?? "")); - const last = escapeMarkdownCell(String(lastReleaseVersion ?? "")); + const first = String(firstReleaseVersion ?? ""); + const last = String(lastReleaseVersion ?? ""); if (firstReleaseVersion && lastReleaseVersion) { if (firstReleaseVersion === lastReleaseVersion) { - rows.push(`| **Release** | ${first} |`); + kvRows.push(["Release", first]); } else { - rows.push(`| **Releases** | ${first} → ${last} |`); + kvRows.push(["Releases", `${first} → ${last}`]); } } else if (lastReleaseVersion) { - rows.push(`| **Release** | ${last} |`); + kvRows.push(["Release", last]); } else if (firstReleaseVersion) { - rows.push(`| **Release** | ${first} |`); + kvRows.push(["Release", first]); } } - rows.push(`| **Events** | ${issue.count ?? 0} |`); - rows.push(`| **Users** | ${issue.userCount ?? 0} |`); + kvRows.push(["Events", String(issue.count ?? 0)]); + kvRows.push(["Users", String(issue.userCount ?? 0)]); if (issue.firstSeen) { let firstSeenLine = new Date(issue.firstSeen).toLocaleString(); if (firstReleaseVersion) { firstSeenLine += ` (in ${firstReleaseVersion})`; } - rows.push(`| **First seen** | ${firstSeenLine} |`); + kvRows.push(["First seen", firstSeenLine]); } if (issue.lastSeen) { let lastSeenLine = new Date(issue.lastSeen).toLocaleString(); if (lastReleaseVersion && lastReleaseVersion !== firstReleaseVersion) { lastSeenLine += ` (in ${lastReleaseVersion})`; } - rows.push(`| **Last seen** | ${lastSeenLine} |`); + kvRows.push(["Last seen", lastSeenLine]); } if (issue.culprit) { - rows.push(`| **Culprit** | ${safeCodeSpan(issue.culprit)} |`); + kvRows.push(["Culprit", safeCodeSpan(issue.culprit)]); } - rows.push(`| **Link** | ${escapeMarkdownCell(issue.permalink ?? "")} |`); + kvRows.push(["Link", issue.permalink ?? ""]); - lines.push("| | |"); - lines.push("|---|---|"); - lines.push(...rows); + lines.push(mdKvTable(kvRows)); if (issue.metadata?.value) { lines.push(""); @@ -690,8 +687,7 @@ function buildBreadcrumbsMarkdown(breadcrumbsEntry: BreadcrumbsEntry): string { const lines: string[] = []; lines.push("### Breadcrumbs"); lines.push(""); - lines.push("| Time | Level | Category | Message |"); - lines.push("|---|---|---|---|"); + lines.push(mdTableHeader(["Time", "Level", "Category", "Message"]).trimEnd()); for (const breadcrumb of breadcrumbs) { const timestamp = breadcrumb.timestamp @@ -721,9 +717,7 @@ function buildBreadcrumbsMarkdown(breadcrumbsEntry: BreadcrumbsEntry): string { const safeMessage = escapeMarkdownCell(message); const safeCategory = escapeMarkdownCell(breadcrumb.category ?? "default"); - lines.push( - `| ${timestamp} | ${level} | ${safeCategory} | ${safeMessage} |` - ); + lines.push(mdRow([timestamp, level, safeCategory, safeMessage]).trimEnd()); } return lines.join("\n"); @@ -904,22 +898,18 @@ function buildEnvironmentMarkdown(event: SentryEvent): string { return ""; } - const rows: string[] = []; + const kvRows: [string, string][] = []; if (contexts.browser) { const name = contexts.browser.name || "Unknown Browser"; const version = contexts.browser.version || ""; - rows.push( - `| **Browser** | ${escapeMarkdownCell(`${name}${version ? ` ${version}` : ""}`)} |` - ); + kvRows.push(["Browser", `${name}${version ? ` ${version}` : ""}`]); } if (contexts.os) { const name = contexts.os.name || "Unknown OS"; const version = contexts.os.version || ""; - rows.push( - `| **OS** | ${escapeMarkdownCell(`${name}${version ? ` ${version}` : ""}`)} |` - ); + kvRows.push(["OS", `${name}${version ? ` ${version}` : ""}`]); } if (contexts.device) { @@ -927,15 +917,15 @@ function buildEnvironmentMarkdown(event: SentryEvent): string { const brand = contexts.device.brand || ""; if (family || brand) { const device = brand ? `${family} (${brand})` : family; - rows.push(`| **Device** | ${escapeMarkdownCell(device)} |`); + kvRows.push(["Device", device]); } } - if (rows.length === 0) { + if (kvRows.length === 0) { return ""; } - return `### Environment\n\n| | |\n|---|---|\n${rows.join("\n")}`; + return mdKvTable(kvRows, "Environment"); } /** @@ -960,22 +950,22 @@ function buildUserMarkdown(event: SentryEvent): string { return ""; } - const rows: string[] = []; + const kvRows: [string, string][] = []; if (user.name) { - rows.push(`| **Name** | ${escapeMarkdownCell(user.name)} |`); + kvRows.push(["Name", user.name]); } if (user.email) { - rows.push(`| **Email** | ${escapeMarkdownCell(user.email)} |`); + kvRows.push(["Email", user.email]); } if (user.username) { - rows.push(`| **Username** | ${escapeMarkdownCell(user.username)} |`); + kvRows.push(["Username", user.username]); } if (user.id) { - rows.push(`| **ID** | ${escapeMarkdownCell(user.id)} |`); + kvRows.push(["ID", user.id]); } if (user.ip_address) { - rows.push(`| **IP** | ${escapeMarkdownCell(user.ip_address)} |`); + kvRows.push(["IP", user.ip_address]); } if (user.geo) { @@ -991,11 +981,11 @@ function buildUserMarkdown(event: SentryEvent): string { parts.push(`(${geo.country_code})`); } if (parts.length > 0) { - rows.push(`| **Location** | ${escapeMarkdownCell(parts.join(", "))} |`); + kvRows.push(["Location", parts.join(", ")]); } } - return `### User\n\n| | |\n|---|---|\n${rows.join("\n")}`; + return mdKvTable(kvRows, "User"); } /** @@ -1050,20 +1040,21 @@ export function formatEventDetails( sections.push(""); // Basic info table - const infoRows: string[] = []; - infoRows.push(`| **Event ID** | \`${event.eventID}\` |`); + const infoKvRows: [string, string][] = []; + infoKvRows.push(["Event ID", `\`${event.eventID}\``]); if (event.dateReceived) { - infoRows.push( - `| **Received** | ${new Date(event.dateReceived).toLocaleString()} |` - ); + infoKvRows.push([ + "Received", + new Date(event.dateReceived).toLocaleString(), + ]); } if (event.location) { - infoRows.push(`| **Location** | ${safeCodeSpan(event.location)} |`); + infoKvRows.push(["Location", safeCodeSpan(event.location)]); } const traceCtx = event.contexts?.trace; if (traceCtx?.trace_id) { - infoRows.push(`| **Trace** | ${safeCodeSpan(traceCtx.trace_id)} |`); + infoKvRows.push(["Trace", safeCodeSpan(traceCtx.trace_id)]); } if (event.sdk?.name || event.sdk?.version) { @@ -1072,19 +1063,15 @@ export function formatEventDetails( const sdkName = event.sdk.name ?? "unknown"; const sdkVersion = event.sdk.version ?? ""; const sdkInfo = `${sdkName}${sdkVersion ? ` ${sdkVersion}` : ""}`; - infoRows.push(`| **SDK** | \`${sdkInfo}\` |`); + infoKvRows.push(["SDK", `\`${sdkInfo}\``]); } if (event.release?.shortVersion) { - infoRows.push( - `| **Release** | ${escapeMarkdownCell(event.release.shortVersion)} |` - ); + infoKvRows.push(["Release", String(event.release.shortVersion)]); } - if (infoRows.length > 0) { - sections.push("| | |"); - sections.push("|---|---|"); - sections.push(...infoRows); + if (infoKvRows.length > 0) { + sections.push(mdKvTable(infoKvRows)); } // User section @@ -1140,11 +1127,14 @@ export function formatEventDetails( sections.push(""); sections.push("### Tags"); sections.push(""); - sections.push("| Key | Value |"); - sections.push("|---|---|"); + sections.push(mdTableHeader(["Key", "Value"]).trimEnd()); for (const tag of event.tags) { - const safeVal = escapeMarkdownCell(String(tag.value)); - sections.push(`| \`${tag.key}\` | ${safeVal} |`); + sections.push( + mdRow([ + `\`${tag.key}\``, + escapeMarkdownCell(String(tag.value)), + ]).trimEnd() + ); } } @@ -1168,21 +1158,17 @@ export function formatOrgDetails(org: SentryOrganization): string { ); lines.push(""); - const rows: string[] = []; - rows.push(`| **Slug** | \`${org.slug || "(none)"}\` |`); - rows.push(`| **Name** | ${escapeMarkdownCell(org.name || "(unnamed)")} |`); - rows.push(`| **ID** | ${org.id} |`); + const kvRows: [string, string][] = []; + kvRows.push(["Slug", `\`${org.slug || "(none)"}\``]); + kvRows.push(["Name", org.name || "(unnamed)"]); + kvRows.push(["ID", String(org.id)]); if (org.dateCreated) { - rows.push( - `| **Created** | ${new Date(org.dateCreated).toLocaleString()} |` - ); + kvRows.push(["Created", new Date(org.dateCreated).toLocaleString()]); } - rows.push(`| **2FA** | ${org.require2FA ? "Required" : "Not required"} |`); - rows.push(`| **Early Adopter** | ${org.isEarlyAdopter ? "Yes" : "No"} |`); + kvRows.push(["2FA", org.require2FA ? "Required" : "Not required"]); + kvRows.push(["Early Adopter", org.isEarlyAdopter ? "Yes" : "No"]); - lines.push("| | |"); - lines.push("|---|---|"); - lines.push(...rows); + lines.push(mdKvTable(kvRows)); const featuresSection = formatFeaturesMarkdown(org.features); if (featuresSection) { @@ -1210,43 +1196,34 @@ export function formatProjectDetails( ); lines.push(""); - const rows: string[] = []; - rows.push(`| **Slug** | \`${project.slug || "(none)"}\` |`); - rows.push( - `| **Name** | ${escapeMarkdownCell(project.name || "(unnamed)")} |` - ); - rows.push(`| **ID** | ${project.id} |`); - rows.push( - `| **Platform** | ${escapeMarkdownCell(project.platform || "Not set")} |` - ); - rows.push(`| **DSN** | \`${dsn || "No DSN available"}\` |`); - rows.push(`| **Status** | ${project.status} |`); + const kvRows: [string, string][] = []; + kvRows.push(["Slug", `\`${project.slug || "(none)"}\``]); + kvRows.push(["Name", project.name || "(unnamed)"]); + kvRows.push(["ID", String(project.id)]); + kvRows.push(["Platform", project.platform || "Not set"]); + kvRows.push(["DSN", `\`${dsn || "No DSN available"}\``]); + kvRows.push(["Status", project.status ?? "unknown"]); if (project.dateCreated) { - rows.push( - `| **Created** | ${new Date(project.dateCreated).toLocaleString()} |` - ); + kvRows.push(["Created", new Date(project.dateCreated).toLocaleString()]); } if (project.organization) { - rows.push( - `| **Organization** | ${escapeMarkdownCell(project.organization.name)} (${safeCodeSpan(project.organization.slug)}) |` - ); + kvRows.push([ + "Organization", + `${project.organization.name} (${safeCodeSpan(project.organization.slug)})`, + ]); } if (project.firstEvent) { - rows.push( - `| **First Event** | ${new Date(project.firstEvent).toLocaleString()} |` - ); + kvRows.push(["First Event", new Date(project.firstEvent).toLocaleString()]); } else { - rows.push("| **First Event** | No events yet |"); + kvRows.push(["First Event", "No events yet"]); } - rows.push(`| **Sessions** | ${project.hasSessions ? "Yes" : "No"} |`); - rows.push(`| **Replays** | ${project.hasReplays ? "Yes" : "No"} |`); - rows.push(`| **Profiles** | ${project.hasProfiles ? "Yes" : "No"} |`); - rows.push(`| **Monitors** | ${project.hasMonitors ? "Yes" : "No"} |`); + kvRows.push(["Sessions", project.hasSessions ? "Yes" : "No"]); + kvRows.push(["Replays", project.hasReplays ? "Yes" : "No"]); + kvRows.push(["Profiles", project.hasProfiles ? "Yes" : "No"]); + kvRows.push(["Monitors", project.hasMonitors ? "Yes" : "No"]); - lines.push("| | |"); - lines.push("|---|---|"); - lines.push(...rows); + lines.push(mdKvTable(kvRows)); const featuresSection = formatFeaturesMarkdown(project.features); if (featuresSection) { diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 8e90a20a..28d9206e 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -119,7 +119,7 @@ export function formatLogTable(logs: SentryLog[]): string { const severity = formatSeverityLabel(log.severity); const message = escapeMarkdownCell(log.message ?? ""); const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; - return `| ${timestamp} | ${severity} | ${message}${trace} |`; + return mdRow([timestamp, severity, `${message}${trace}`]).trimEnd(); }) .join("\n"); diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index ccd20f11..8576edb6 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -169,8 +169,10 @@ export function mdKvTable( // Escape backslashes first, then replace pipes with a Unicode box character // so that backslash-pipe sequences in values don't produce escaped pipes in // the rendered output. Newlines are collapsed to spaces. + // Only replace structural characters that break table syntax. + // Content escaping (<>, \, _*`) is the caller's responsibility. lines.push( - `| **${label}** | ${value.replace(/\n/g, " ").replace(/\\/g, "\\\\").replace(//g, ">").replace(/\|/g, "\u2502")} |` + `| **${label}** | ${value.replace(/\n/g, " ").replace(/\|/g, "\u2502")} |` ); } return lines.join("\n"); diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index b638b050..4cc03a04 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -10,6 +10,7 @@ import { divider, escapeMarkdownCell, isPlainOutput, + mdKvTable, mdRow, mdTableHeader, renderMarkdown, @@ -115,10 +116,7 @@ export function formatTracesHeader(): string { */ export function formatTraceTable(items: TransactionListItem[]): string { const rows = items - .map((item) => { - const [traceId, transaction, duration, when] = buildTraceRowCells(item); - return `| ${traceId} | ${transaction} | ${duration} | ${when} |`; - }) + .map((item) => mdRow(buildTraceRowCells(item)).trimEnd()) .join("\n"); return renderMarkdown(`${mdTableHeader(TRACE_TABLE_COLS)}\n${rows}`); } @@ -251,25 +249,22 @@ export function computeTraceSummary( * @returns Rendered terminal string */ export function formatTraceSummary(summary: TraceSummary): string { - const rows: string[] = []; + const kvRows: [string, string][] = []; if (summary.rootTransaction) { const opPrefix = summary.rootOp ? `[\`${summary.rootOp}\`] ` : ""; - rows.push( - `| **Root** | ${opPrefix}${escapeMarkdownCell(summary.rootTransaction)} |` - ); + kvRows.push(["Root", `${opPrefix}${summary.rootTransaction}`]); } - rows.push(`| **Duration** | ${formatTraceDuration(summary.duration)} |`); - rows.push(`| **Spans** | ${summary.spanCount} |`); + kvRows.push(["Duration", formatTraceDuration(summary.duration)]); + kvRows.push(["Spans", String(summary.spanCount)]); if (summary.projects.length > 0) { - rows.push(`| **Projects** | ${summary.projects.join(", ")} |`); + kvRows.push(["Projects", summary.projects.join(", ")]); } if (Number.isFinite(summary.startTimestamp) && summary.startTimestamp > 0) { const date = new Date(summary.startTimestamp * 1000); - rows.push(`| **Started** | ${date.toLocaleString("sv-SE")} |`); + kvRows.push(["Started", date.toLocaleString("sv-SE")]); } - const table = `| | |\n|---|---|\n${rows.join("\n")}`; - const md = `## Trace \`${summary.traceId}\`\n\n${table}\n`; + const md = `## Trace \`${summary.traceId}\`\n\n${mdKvTable(kvRows)}\n`; return renderMarkdown(md); } From 302bb2fea4af937d36f1ca18365735f15aafa87a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 20:43:07 +0000 Subject: [PATCH 42/52] feat(formatters): add StreamingTable for bordered streaming log/trace output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add StreamingTable class to text-table.ts that renders incrementally: header (top border + column names + separator), row-by-row with side borders, and footer (bottom border) on stream end. Log streaming (--follow) now uses bordered tables in TTY mode: - createLogStreamingTable() factory with pre-configured column hints - SIGINT handler prints bottom border before exit - Plain mode (non-TTY) still emits raw markdown rows for pipe safety Trace streaming gets createTraceStreamingTable() factory (not yet wired to command — traces don't have --follow yet, but the factory is ready). Extract buildLogRowCells() and export buildTraceRowCells() so both streaming (StreamingTable.row) and batch (formatLogTable/formatTraceTable) paths share the same cell-building logic. --- src/commands/log/list.ts | 40 +++++-- src/lib/formatters/log.ts | 81 ++++++++------ src/lib/formatters/text-table.ts | 175 +++++++++++++++++++++++++++++++ src/lib/formatters/trace.ts | 50 +++++++-- test/lib/formatters/log.test.ts | 37 +++++-- 5 files changed, 326 insertions(+), 57 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 85fece58..f5843ff3 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -12,9 +12,12 @@ import { listLogs } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { AuthError, stringifyUnknown } from "../../lib/errors.js"; import { + buildLogRowCells, + createLogStreamingTable, formatLogRow, formatLogsHeader, formatLogTable, + isPlainOutput, writeFooter, writeJson, } from "../../lib/formatters/index.js"; @@ -74,12 +77,24 @@ function parseFollow(value: string): number { /** * Write logs to output in the appropriate format. + * + * When a StreamingTable is provided (TTY mode), renders rows through the + * bordered table. Otherwise falls back to plain markdown rows. */ -function writeLogs(stdout: Writer, logs: SentryLog[], asJson: boolean): void { +function writeLogs( + stdout: Writer, + logs: SentryLog[], + asJson: boolean, + table?: import("../../lib/formatters/text-table.js").StreamingTable +): void { if (asJson) { for (const log of logs) { writeJson(stdout, log); } + } else if (table) { + for (const log of logs) { + stdout.write(table.row(buildLogRowCells(log))); + } } else { for (const log of logs) { stdout.write(formatLogRow(log)); @@ -158,7 +173,12 @@ async function executeFollowMode(options: FollowModeOptions): Promise { stderr.write("\n"); } - // Track if header has been printed (for human mode) + // In TTY mode, use a bordered StreamingTable for aligned columns. + // In plain mode, use raw markdown rows for pipe-friendly output. + const plain = flags.json || isPlainOutput(); + const table = plain ? undefined : createLogStreamingTable(); + + // Track if header has been printed (for human/plain mode) let headerPrinted = false; // Initial fetch: only last minute for follow mode (we want recent logs, not historical) @@ -170,13 +190,21 @@ async function executeFollowMode(options: FollowModeOptions): Promise { // Print header before initial logs (human mode only) if (!flags.json && initialLogs.length > 0) { - stdout.write(formatLogsHeader()); + stdout.write(table ? table.header() : formatLogsHeader()); headerPrinted = true; } // Reverse for chronological order (API returns newest first, tail -f shows oldest first) const chronologicalInitial = [...initialLogs].reverse(); - writeLogs(stdout, chronologicalInitial, flags.json); + writeLogs(stdout, chronologicalInitial, flags.json, table); + + // Print bottom border on Ctrl+C so the table closes cleanly + if (table) { + process.once("SIGINT", () => { + stdout.write(table.footer()); + process.exit(0); + }); + } // Track newest timestamp (logs are sorted -timestamp, so first is newest) // Use current time as fallback to avoid fetching old logs when initial fetch is empty @@ -200,13 +228,13 @@ async function executeFollowMode(options: FollowModeOptions): Promise { if (newestLog) { // Print header before first logs if not already printed if (!(flags.json || headerPrinted)) { - stdout.write(formatLogsHeader()); + stdout.write(table ? table.header() : formatLogsHeader()); headerPrinted = true; } // Reverse for chronological order (oldest first for tail -f style) const chronologicalNew = [...newLogs].reverse(); - writeLogs(stdout, chronologicalNew, flags.json); + writeLogs(stdout, chronologicalNew, flags.json, table); // Update timestamp AFTER successful write to avoid losing logs on write failure lastTimestamp = newestLog.timestamp_precise; diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 28d9206e..6a283703 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -8,15 +8,14 @@ import type { DetailedSentryLog, SentryLog } from "../../types/index.js"; import { buildTraceUrl } from "../sentry-urls.js"; import { colorTag, - divider, escapeMarkdownCell, escapeMarkdownInline, - isPlainOutput, mdKvTable, mdRow, mdTableHeader, renderMarkdown, } from "./markdown.js"; +import { StreamingTable, type StreamingTableOptions } from "./text-table.js"; /** Markdown color tag names for log severity levels */ const SEVERITY_TAGS: Record[0]> = { @@ -66,39 +65,70 @@ function formatTimestamp(timestamp: string): string { } /** - * Format a single log entry for human-readable output. + * Extract cell values for a log row (shared by streaming and batch paths). * - * In plain mode (non-TTY / `SENTRY_PLAIN_OUTPUT=1`): emits a markdown table - * row so streamed output composes into a valid CommonMark document. - * In rendered mode (TTY): emits padded ANSI-colored text for live display. + * @param log - The log entry + * @param padSeverity - Whether to pad severity to 7 chars for alignment + * @returns `[timestamp, severity, message]` strings + */ +export function buildLogRowCells( + log: SentryLog, + padSeverity = true +): [string, string, string] { + const timestamp = formatTimestamp(log.timestamp); + const level = padSeverity + ? formatSeverity(log.severity) + : formatSeverityLabel(log.severity); + const message = escapeMarkdownCell(log.message ?? ""); + const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; + return [timestamp, level, `${message}${trace}`]; +} + +/** + * Format a single log entry as a plain markdown table row. + * Used for non-TTY / piped output where StreamingTable isn't appropriate. * * @param log - The log entry to format * @returns Formatted log line with newline */ export function formatLogRow(log: SentryLog): string { - const timestamp = formatTimestamp(log.timestamp); - // Use formatSeverity() for per-level ANSI color (red/yellow/cyan/muted), - // matching the batch-mode formatLogTable path. - const level = formatSeverity(log.severity); - const message = escapeMarkdownCell(log.message ?? ""); - const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; - return mdRow([timestamp, level, `${message}${trace}`]); + return mdRow(buildLogRowCells(log)); +} + +/** Hint rows for column width estimation in streaming mode. */ +const LOG_HINT_ROWS: string[][] = [ + ["2026-01-15 23:59:59", "WARNING", "A typical log message with some detail"], +]; + +/** + * Create a StreamingTable configured for log output. + * + * @param options - Override default table options + * @returns A StreamingTable with log-specific column configuration + */ +export function createLogStreamingTable( + options: Partial = {} +): StreamingTable { + return new StreamingTable([...LOG_TABLE_COLS], { + hintRows: LOG_HINT_ROWS, + // Timestamp and Level are fixed-width; Message gets the rest + shrinkable: [false, false, true], + truncate: false, + ...options, + }); } /** - * Format column header for logs list (used in streaming/follow mode). + * Format column header for logs list in plain (non-TTY) mode. * - * In plain mode: emits a proper markdown table header + separator row so that + * Emits a proper markdown table header + separator row so that * the streamed rows compose into a valid CommonMark document when redirected. - * In rendered mode: emits an ANSI-muted text header with a rule separator. + * In TTY mode, use {@link createLogStreamingTable} instead. * * @returns Header string (includes trailing newline) */ export function formatLogsHeader(): string { - if (isPlainOutput()) { - return `${mdTableHeader(LOG_TABLE_COLS)}\n`; - } - return `${mdRow(LOG_TABLE_COLS.map((c) => `**${c}**`))}${divider(80)}\n`; + return `${mdTableHeader(LOG_TABLE_COLS)}\n`; } /** @@ -111,16 +141,7 @@ export function formatLogsHeader(): string { */ export function formatLogTable(logs: SentryLog[]): string { const rows = logs - .map((log) => { - const timestamp = formatTimestamp(log.timestamp); - // formatSeverity wraps the padEnd label inside a color tag, so .trim() - // on the result would be a no-op. Use formatSeverityLabel (no padding) - // for the batch table which handles its own column sizing. - const severity = formatSeverityLabel(log.severity); - const message = escapeMarkdownCell(log.message ?? ""); - const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; - return mdRow([timestamp, severity, `${message}${trace}`]).trimEnd(); - }) + .map((log) => mdRow(buildLogRowCells(log, false)).trimEnd()) .join("\n"); return renderMarkdown(`${mdTableHeader(LOG_TABLE_COLS)}\n${rows}`); diff --git a/src/lib/formatters/text-table.ts b/src/lib/formatters/text-table.ts index df272ba2..4abf6b1f 100644 --- a/src/lib/formatters/text-table.ts +++ b/src/lib/formatters/text-table.ts @@ -537,3 +537,178 @@ function horizontalLine( const segments = columnWidths.map((w) => chars.horizontal.repeat(w)); return `${chars.left}${segments.join(chars.junction)}${chars.right}`; } + +/** Options for creating a streaming table. */ +export type StreamingTableOptions = { + /** Border style. @default "rounded" */ + borderStyle?: BorderStyle; + /** Horizontal cell padding (each side). @default 1 */ + cellPadding?: number; + /** Maximum table width in columns. @default process.stdout.columns or 80 */ + maxWidth?: number; + /** Per-column alignment (indexed by column). Defaults to "left". */ + alignments?: Array; + /** Per-column minimum content widths. Columns will not shrink below these. */ + minWidths?: number[]; + /** Per-column shrinkable flags. Non-shrinkable columns keep intrinsic width. */ + shrinkable?: boolean[]; + /** Truncate cells to one line with "…" instead of wrapping. @default true */ + truncate?: boolean; + /** + * Hint rows used for column width measurement. + * Pass representative sample data so column widths are computed correctly + * without needing the full dataset upfront. + */ + hintRows?: string[][]; +}; + +/** + * A bordered table that renders incrementally — header first, then one row + * at a time, then a bottom border at the end. Column widths are fixed at + * construction time based on headers + optional hint rows. + * + * Usage: + * ```ts + * const table = new StreamingTable(["Time", "Level", "Message"], opts); + * writer.write(table.header()); + * writer.write(table.row(["2026-02-28 10:00", "ERROR", "something broke"])); + * writer.write(table.footer()); + * ``` + * + * In plain-output mode (non-TTY), emits raw CommonMark markdown table syntax + * so piped/redirected output remains a valid document. + */ +export class StreamingTable { + /** @internal */ readonly columnWidths: number[]; + /** @internal */ readonly border: BorderCharacters; + /** @internal */ readonly cellPadding: number; + /** @internal */ readonly alignments: Array; + /** @internal */ readonly headers: string[]; + /** @internal */ readonly truncate: boolean; + + constructor(headers: string[], options: StreamingTableOptions = {}) { + const { + borderStyle = "rounded", + cellPadding = 1, + maxWidth = process.stdout.columns || 80, + alignments = [], + minWidths = [], + shrinkable = [], + truncate = true, + hintRows = [], + } = options; + + this.headers = headers; + this.border = BorderChars[borderStyle]; + this.cellPadding = cellPadding; + this.alignments = alignments; + this.truncate = truncate; + + const colCount = headers.length; + const intrinsicWidths = measureIntrinsicWidths( + headers, + hintRows, + colCount, + { cellPadding, minWidths } + ); + + const borderOverhead = 2 + (colCount - 1); + const maxContentWidth = Math.max(colCount, maxWidth - borderOverhead); + this.columnWidths = fitColumns(intrinsicWidths, maxContentWidth, { + cellPadding, + fitter: "balanced", + minWidths, + shrinkable, + }); + } + + /** + * Render the top border, header row, and header separator. + * Call once at the start of streaming. + */ + header(): string { + const { border, columnWidths, cellPadding, alignments, headers } = this; + const hz = border.horizontal; + const lines: string[] = []; + + // Top border + lines.push( + horizontalLine(columnWidths, { + left: border.topLeft, + junction: border.topT, + right: border.topRight, + horizontal: hz, + }) + ); + + // Header cells + const wrappedHeader = wrapRow(headers, columnWidths, cellPadding, false); + const rowHeight = Math.max(1, ...wrappedHeader.map((c) => c.length)); + for (let line = 0; line < rowHeight; line++) { + const cellTexts: string[] = []; + for (let c = 0; c < columnWidths.length; c++) { + const cellLines = wrappedHeader[c] ?? [""]; + const text = cellLines[line] ?? ""; + const align = alignments[c] ?? "left"; + const colW = columnWidths[c] ?? 3; + cellTexts.push(padCell(text, colW, align, cellPadding)); + } + lines.push( + `${border.vertical}${cellTexts.join(border.vertical)}${border.vertical}` + ); + } + + // Header separator + lines.push( + horizontalLine(columnWidths, { + left: border.leftT, + junction: border.cross, + right: border.rightT, + horizontal: hz, + }) + ); + + return `${lines.join("\n")}\n`; + } + + /** + * Render a single data row with side borders. + * Call once per data item as it arrives. + */ + row(cells: string[]): string { + const { border, columnWidths, cellPadding, alignments, truncate } = this; + const wrappedCells = wrapRow(cells, columnWidths, cellPadding, truncate); + const rowHeight = Math.max(1, ...wrappedCells.map((c) => c.length)); + const lines: string[] = []; + + for (let line = 0; line < rowHeight; line++) { + const cellTexts: string[] = []; + for (let c = 0; c < columnWidths.length; c++) { + const cellLines = wrappedCells[c] ?? [""]; + const text = cellLines[line] ?? ""; + const align = alignments[c] ?? "left"; + const colW = columnWidths[c] ?? 3; + cellTexts.push(padCell(text, colW, align, cellPadding)); + } + lines.push( + `${border.vertical}${cellTexts.join(border.vertical)}${border.vertical}` + ); + } + + return `${lines.join("\n")}\n`; + } + + /** + * Render the bottom border. + * Call once when the stream ends. + */ + footer(): string { + const { border, columnWidths } = this; + return `${horizontalLine(columnWidths, { + left: border.bottomLeft, + junction: border.bottomT, + right: border.bottomRight, + horizontal: border.horizontal, + })}\n`; + } +} diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 4cc03a04..60279499 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -7,14 +7,13 @@ import type { TraceSpan, TransactionListItem } from "../../types/index.js"; import { formatRelativeTime } from "./human.js"; import { - divider, escapeMarkdownCell, - isPlainOutput, mdKvTable, mdRow, mdTableHeader, renderMarkdown, } from "./markdown.js"; +import { StreamingTable, type StreamingTableOptions } from "./text-table.js"; /** * Format a duration in milliseconds to a human-readable string. @@ -60,7 +59,7 @@ const TRACE_TABLE_COLS = ["Trace ID", "Transaction", "Duration:", "When"]; * @param item - Transaction list item from the API * @returns `[traceId, transaction, duration, when]` markdown-safe strings */ -function buildTraceRowCells( +export function buildTraceRowCells( item: TransactionListItem ): [string, string, string, string] { return [ @@ -86,22 +85,51 @@ export function formatTraceRow(item: TransactionListItem): string { } /** - * Format column header for traces list (streaming mode). + * Format column header for traces list in plain (non-TTY) mode. * - * In plain mode: emits a proper markdown table header + separator row so - * that streamed rows compose into a valid CommonMark document when redirected. - * In rendered mode: emits an ANSI-muted text header with a rule separator. + * Emits a proper markdown table header + separator row so that + * the streamed rows compose into a valid CommonMark document when redirected. + * In TTY mode, use {@link createTraceStreamingTable} instead. * * @returns Header string (includes trailing newline) */ export function formatTracesHeader(): string { - if (isPlainOutput()) { - return `${mdTableHeader(TRACE_TABLE_COLS)}\n`; - } + return `${mdTableHeader(TRACE_TABLE_COLS)}\n`; +} + +/** Hint rows for column width estimation in streaming mode. */ +const TRACE_HINT_ROWS: string[][] = [ + [ + "`abcdef1234567890abcdef1234567890`", + "GET /api/v1/some-endpoint", + "1.23s", + "2 hours ago", + ], +]; + +/** + * Create a StreamingTable configured for trace output. + * + * @param options - Override default table options + * @returns A StreamingTable with trace-specific column configuration + */ +export function createTraceStreamingTable( + options: Partial = {} +): StreamingTable { + const alignments = TRACE_TABLE_COLS.map((c) => + c.endsWith(":") ? ("right" as const) : null + ); const names = TRACE_TABLE_COLS.map((c) => c.endsWith(":") ? c.slice(0, -1) : c ); - return `${mdRow(names.map((n) => `**${n}**`))}${divider(96)}\n`; + return new StreamingTable(names, { + hintRows: TRACE_HINT_ROWS, + alignments, + // Trace ID and Duration/When are fixed; Transaction gets the rest + shrinkable: [false, true, false, false], + truncate: true, + ...options, + }); } /** diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index 67fa6c5b..eac02792 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { + createLogStreamingTable, formatLogDetails, formatLogRow, formatLogsHeader, @@ -151,27 +152,43 @@ describe("formatLogRow (rendered mode)", () => { }); }); -describe("formatLogsHeader (rendered mode)", () => { - useRenderedMode(); - - test("contains column titles", () => { - const result = stripAnsi(formatLogsHeader()); +describe("createLogStreamingTable", () => { + test("header() contains column titles and box-drawing borders", () => { + const table = createLogStreamingTable({ maxWidth: 80 }); + const result = table.header(); expect(result).toContain("Timestamp"); expect(result).toContain("Level"); expect(result).toContain("Message"); + // Box-drawing border characters + expect(result).toContain("─"); + expect(result).toContain("╭"); }); - test("contains divider line", () => { - const result = formatLogsHeader(); + test("row() renders cells with side borders", () => { + const table = createLogStreamingTable({ maxWidth: 80 }); + const result = table.row(["2026-01-15 10:00:00", "ERROR", "something"]); + + expect(result).toContain("2026-01-15 10:00:00"); + expect(result).toContain("ERROR"); + expect(result).toContain("something"); + // Side borders + expect(result).toContain("│"); + }); + + test("footer() renders bottom border", () => { + const table = createLogStreamingTable({ maxWidth: 80 }); + const result = table.footer(); - // Should have divider characters expect(result).toContain("─"); + expect(result).toContain("╯"); }); test("ends with newline", () => { - const result = formatLogsHeader(); - expect(result).toEndWith("\n"); + const table = createLogStreamingTable({ maxWidth: 80 }); + expect(table.header()).toEndWith("\n"); + expect(table.row(["a", "b", "c"])).toEndWith("\n"); + expect(table.footer()).toEndWith("\n"); }); }); From 63ec308d4b32649a3f30c8587c4cdc90b1c7f241 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 21:16:13 +0000 Subject: [PATCH 43/52] fix(formatters): escape release versions and rootTransaction, fix batch table double-render - Escape firstReleaseVersion/lastReleaseVersion in issue firstSeen/lastSeen cells via escapeMarkdownCell() to prevent pipe/backslash chars breaking markdown table structure - Escape rootTransaction in formatTraceSummary for the same reason - Fix formatLogTable and formatTraceTable which were passing mdRow output (already ANSI-rendered in TTY mode) through renderMarkdown() a second time, mangling the table. Both functions now use renderTextTable() directly in TTY mode and mdRow/mdTableHeader in plain mode (same pattern as writeTable) - Remove stale @param isMultiProject from writeListHeader JSDoc --- src/commands/issue/list.ts | 1 - src/lib/formatters/human.ts | 4 ++-- src/lib/formatters/log.ts | 25 +++++++++++++++++++------ src/lib/formatters/trace.ts | 34 ++++++++++++++++++++++++++++------ 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 70c9770a..e4b06a75 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -107,7 +107,6 @@ function parseSort(value: string): SortValue { * * @param stdout - Output writer * @param title - Section title - * @param isMultiProject - Whether to show ALIAS column for multi-project mode */ function writeListHeader(stdout: Writer, title: string): void { stdout.write(`${title}:\n\n`); diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index d974b63d..3230e26c 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -560,14 +560,14 @@ export function formatIssueDetails(issue: SentryIssue): string { if (issue.firstSeen) { let firstSeenLine = new Date(issue.firstSeen).toLocaleString(); if (firstReleaseVersion) { - firstSeenLine += ` (in ${firstReleaseVersion})`; + firstSeenLine += ` (in ${escapeMarkdownCell(String(firstReleaseVersion))})`; } kvRows.push(["First seen", firstSeenLine]); } if (issue.lastSeen) { let lastSeenLine = new Date(issue.lastSeen).toLocaleString(); if (lastReleaseVersion && lastReleaseVersion !== firstReleaseVersion) { - lastSeenLine += ` (in ${lastReleaseVersion})`; + lastSeenLine += ` (in ${escapeMarkdownCell(String(lastReleaseVersion))})`; } kvRows.push(["Last seen", lastSeenLine]); } diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 6a283703..c68e9e87 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -10,12 +10,19 @@ import { colorTag, escapeMarkdownCell, escapeMarkdownInline, + isPlainOutput, mdKvTable, mdRow, mdTableHeader, + renderInlineMarkdown, renderMarkdown, + stripColorTags, } from "./markdown.js"; -import { StreamingTable, type StreamingTableOptions } from "./text-table.js"; +import { + renderTextTable, + StreamingTable, + type StreamingTableOptions, +} from "./text-table.js"; /** Markdown color tag names for log severity levels */ const SEVERITY_TAGS: Record[0]> = { @@ -140,11 +147,17 @@ export function formatLogsHeader(): string { * @returns Rendered terminal string with Unicode-bordered table */ export function formatLogTable(logs: SentryLog[]): string { - const rows = logs - .map((log) => mdRow(buildLogRowCells(log, false)).trimEnd()) - .join("\n"); - - return renderMarkdown(`${mdTableHeader(LOG_TABLE_COLS)}\n${rows}`); + if (isPlainOutput()) { + const rows = logs + .map((log) => mdRow(buildLogRowCells(log, false)).trimEnd()) + .join("\n"); + return `${stripColorTags(mdTableHeader(LOG_TABLE_COLS))}\n${rows}\n`; + } + const headers = [...LOG_TABLE_COLS]; + const rows = logs.map((log) => + buildLogRowCells(log, false).map((c) => renderInlineMarkdown(c)) + ); + return renderTextTable(headers, rows); } /** diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 60279499..2ab3e981 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -8,12 +8,19 @@ import type { TraceSpan, TransactionListItem } from "../../types/index.js"; import { formatRelativeTime } from "./human.js"; import { escapeMarkdownCell, + isPlainOutput, mdKvTable, mdRow, mdTableHeader, + renderInlineMarkdown, renderMarkdown, + stripColorTags, } from "./markdown.js"; -import { StreamingTable, type StreamingTableOptions } from "./text-table.js"; +import { + renderTextTable, + StreamingTable, + type StreamingTableOptions, +} from "./text-table.js"; /** * Format a duration in milliseconds to a human-readable string. @@ -143,10 +150,22 @@ export function createTraceStreamingTable( * @returns Rendered terminal string with Unicode-bordered table */ export function formatTraceTable(items: TransactionListItem[]): string { - const rows = items - .map((item) => mdRow(buildTraceRowCells(item)).trimEnd()) - .join("\n"); - return renderMarkdown(`${mdTableHeader(TRACE_TABLE_COLS)}\n${rows}`); + if (isPlainOutput()) { + const rows = items + .map((item) => mdRow(buildTraceRowCells(item)).trimEnd()) + .join("\n"); + return `${stripColorTags(mdTableHeader(TRACE_TABLE_COLS))}\n${rows}\n`; + } + const headers = TRACE_TABLE_COLS.map((c) => + c.endsWith(":") ? c.slice(0, -1) : c + ); + const rows = items.map((item) => + buildTraceRowCells(item).map((c) => renderInlineMarkdown(c)) + ); + const alignments = TRACE_TABLE_COLS.map((c) => + c.endsWith(":") ? ("right" as const) : ("left" as const) + ); + return renderTextTable(headers, rows, { alignments }); } /** Trace summary computed from a span tree */ @@ -281,7 +300,10 @@ export function formatTraceSummary(summary: TraceSummary): string { if (summary.rootTransaction) { const opPrefix = summary.rootOp ? `[\`${summary.rootOp}\`] ` : ""; - kvRows.push(["Root", `${opPrefix}${summary.rootTransaction}`]); + kvRows.push([ + "Root", + `${opPrefix}${escapeMarkdownCell(summary.rootTransaction)}`, + ]); } kvRows.push(["Duration", formatTraceDuration(summary.duration)]); kvRows.push(["Spans", String(summary.spanCount)]); From 2b1f0baf3089a56536bbd55048c804cce335ce21 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 21:31:47 +0000 Subject: [PATCH 44/52] fix(test): reorder stripAnsi to strip color tags before ANSI codes Reorder the two .replace() calls in the test helper so that color tags (, ) are removed first. This prevents CodeQL's 'incomplete multi-character sanitization' alert where ANSI code removal could theoretically join fragments into tag-like sequences. --- test/lib/formatters/human.utils.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/lib/formatters/human.utils.test.ts b/test/lib/formatters/human.utils.test.ts index 110fbdcd..08006dd4 100644 --- a/test/lib/formatters/human.utils.test.ts +++ b/test/lib/formatters/human.utils.test.ts @@ -24,10 +24,14 @@ import { } from "../../../src/lib/formatters/human.js"; import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; -// Helper to strip ANSI codes and markdown color tags for content testing +// Helper to strip ANSI codes and markdown color tags for content testing. +// Strips color tags first to avoid incomplete multi-character sanitization +// (ANSI removal could otherwise join fragments into tag-like sequences). function stripAnsi(str: string): string { + let result = str.replace(/<\/?[a-z]+>/g, ""); // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars - return str.replace(/\x1b\[[0-9;]*m/g, "").replace(/<\/?[a-z]+>/g, ""); + result = result.replace(/\x1b\[[0-9;]*m/g, ""); + return result; } // Status Formatting From 09dfd52a81a1b788dbd714d0e4b594d85b020fd3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 22:00:50 +0000 Subject: [PATCH 45/52] fix(formatters): restore bold+underline for alias indicators in short ID column Replace markdown **bold** syntax with chalk boldUnderline() for alias highlighting in formatShortId and formatShortIdWithAlias. The markdown approach only produced bold text; the original used bold+underline via chalk to make alias indicators visually distinct. ANSI codes survive the table rendering pipeline and display correctly in TTY mode. --- src/lib/formatters/human.ts | 12 ++++++------ test/lib/formatters/human.property.test.ts | 4 ++-- test/lib/formatters/human.test.ts | 20 ++++++++++++-------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 3230e26c..c7a5e2fa 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -25,7 +25,7 @@ import type { Writer, } from "../../types/index.js"; import { withSerializeSpan } from "../telemetry.js"; -import { type FixabilityTier, muted } from "./colors.js"; +import { boldUnderline, type FixabilityTier, muted } from "./colors.js"; import { colorTag, escapeMarkdownCell, @@ -323,20 +323,20 @@ function formatShortIdWithAlias( if (part?.startsWith(aliasUpper)) { const result = projectParts.map((p, idx) => { if (idx === i) { - return `**${p.slice(0, aliasLen)}**${p.slice(aliasLen)}`; + return `${boldUnderline(p.slice(0, aliasLen))}${p.slice(aliasLen)}`; } return p; }); - return `${result.join("-")}-**${issueSuffix}**`; + return `${result.join("-")}-${boldUnderline(issueSuffix)}`; } } } const projectPortion = projectParts.join("-"); if (projectPortion.startsWith(aliasUpper)) { - const highlighted = `**${projectPortion.slice(0, aliasLen)}**`; + const highlighted = boldUnderline(projectPortion.slice(0, aliasLen)); const rest = projectPortion.slice(aliasLen); - return `${highlighted}${rest}-**${issueSuffix}**`; + return `${highlighted}${rest}-${boldUnderline(issueSuffix)}`; } return null; @@ -373,7 +373,7 @@ export function formatShortId( const prefix = `${projectSlug.toUpperCase()}-`; if (upperShortId.startsWith(prefix)) { const suffix = shortId.slice(prefix.length); - return `${prefix}**${suffix.toUpperCase()}**`; + return `${prefix}${boldUnderline(suffix.toUpperCase())}`; } } diff --git a/test/lib/formatters/human.property.test.ts b/test/lib/formatters/human.property.test.ts index c4261c48..e9209c43 100644 --- a/test/lib/formatters/human.property.test.ts +++ b/test/lib/formatters/human.property.test.ts @@ -30,9 +30,9 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, ""); } -/** Strip both ANSI escape codes and markdown bold markers. */ +/** Strip ANSI escape codes for content testing. */ function stripFormatting(s: string): string { - return stripAnsi(s).replace(/\*\*/g, ""); + return stripAnsi(s); } // Arbitraries diff --git a/test/lib/formatters/human.test.ts b/test/lib/formatters/human.test.ts index 7a491a61..b0779c79 100644 --- a/test/lib/formatters/human.test.ts +++ b/test/lib/formatters/human.test.ts @@ -7,6 +7,7 @@ */ import { describe, expect, test } from "bun:test"; +import chalk from "chalk"; import { formatShortId, formatUserIdentity, @@ -21,10 +22,10 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, ""); } -/** Strip both ANSI escape codes and markdown bold markers. */ +/** Strip ANSI escape codes for content testing. */ function stripFormatting(s: string): string { // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars - return s.replace(/\x1b\[[0-9;]*m/g, "").replace(/\*\*/g, ""); + return s.replace(/\x1b\[[0-9;]*m/g, ""); } describe("formatShortId edge cases", () => { @@ -53,8 +54,10 @@ describe("formatShortId formatting", () => { test("single project mode applies formatting to suffix", () => { const result = formatShortId("CRAFT-G", { projectSlug: "craft" }); expect(stripFormatting(result)).toBe("CRAFT-G"); - // Suffix should be wrapped in markdown bold - expect(result).toContain("**G**"); + // Suffix should be bold+underlined when color is active + if (chalk.level > 0) { + expect(result).not.toBe(stripFormatting(result)); + } }); test("multi-project mode applies formatting to suffix", () => { @@ -64,15 +67,16 @@ describe("formatShortId formatting", () => { isMultiProject: true, }); expect(stripFormatting(result)).toBe("SPOTLIGHT-ELECTRON-4Y"); - // Alias char and suffix should be bold - expect(result).toContain("**E**"); - expect(result).toContain("**4Y**"); + // Alias char and suffix should be bold+underlined when color is active + if (chalk.level > 0) { + expect(result).not.toBe(stripFormatting(result)); + } }); test("no formatting when no options provided", () => { const result = formatShortId("CRAFT-G"); expect(result).toBe("CRAFT-G"); - expect(result).not.toContain("**"); + expect(result).toBe(stripFormatting(result)); }); }); From dc879a91b194f61180796679c3e0029e918eb0c9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 22:04:30 +0000 Subject: [PATCH 46/52] fix(formatters): use colorTag bu for alias bold+underline instead of ANSI Use the existing colorTag system with a new 'bu' (bold+underline) tag instead of direct chalk calls. This keeps alias indicators as markdown-compatible content that strips cleanly in plain mode and renders through the custom markdown renderer in TTY mode. --- src/lib/formatters/human.ts | 12 +++++------ src/lib/formatters/markdown.ts | 3 ++- test/lib/formatters/human.property.test.ts | 4 ++-- test/lib/formatters/human.test.ts | 24 +++++++++++----------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index c7a5e2fa..f75ff931 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -25,7 +25,7 @@ import type { Writer, } from "../../types/index.js"; import { withSerializeSpan } from "../telemetry.js"; -import { boldUnderline, type FixabilityTier, muted } from "./colors.js"; +import { type FixabilityTier, muted } from "./colors.js"; import { colorTag, escapeMarkdownCell, @@ -323,20 +323,20 @@ function formatShortIdWithAlias( if (part?.startsWith(aliasUpper)) { const result = projectParts.map((p, idx) => { if (idx === i) { - return `${boldUnderline(p.slice(0, aliasLen))}${p.slice(aliasLen)}`; + return `${colorTag("bu", p.slice(0, aliasLen))}${p.slice(aliasLen)}`; } return p; }); - return `${result.join("-")}-${boldUnderline(issueSuffix)}`; + return `${result.join("-")}-${colorTag("bu", issueSuffix)}`; } } } const projectPortion = projectParts.join("-"); if (projectPortion.startsWith(aliasUpper)) { - const highlighted = boldUnderline(projectPortion.slice(0, aliasLen)); + const highlighted = colorTag("bu", projectPortion.slice(0, aliasLen)); const rest = projectPortion.slice(aliasLen); - return `${highlighted}${rest}-${boldUnderline(issueSuffix)}`; + return `${highlighted}${rest}-${colorTag("bu", issueSuffix)}`; } return null; @@ -373,7 +373,7 @@ export function formatShortId( const prefix = `${projectSlug.toUpperCase()}-`; if (upperShortId.startsWith(prefix)) { const suffix = shortId.slice(prefix.length); - return `${prefix}${boldUnderline(suffix.toUpperCase())}`; + return `${prefix}${colorTag("bu", suffix.toUpperCase())}`; } } diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 8576edb6..58213aa7 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -195,7 +195,7 @@ export function divider(width = 80): string { * ANSI color. In plain (non-TTY) mode the tags are stripped, leaving only * the inner text. * - * Supported tags: red, green, yellow, blue, magenta, cyan, muted + * Supported tags: red, green, yellow, blue, magenta, cyan, muted, bu (bold+underline) */ const COLOR_TAGS: Record string> = { red: (t) => chalk.hex(COLORS.red)(t), @@ -205,6 +205,7 @@ const COLOR_TAGS: Record string> = { magenta: (t) => chalk.hex(COLORS.magenta)(t), cyan: (t) => chalk.hex(COLORS.cyan)(t), muted: (t) => chalk.hex(COLORS.muted)(t), + bu: (t) => chalk.bold.underline(t), }; /** diff --git a/test/lib/formatters/human.property.test.ts b/test/lib/formatters/human.property.test.ts index e9209c43..aafca2e6 100644 --- a/test/lib/formatters/human.property.test.ts +++ b/test/lib/formatters/human.property.test.ts @@ -30,9 +30,9 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, ""); } -/** Strip ANSI escape codes for content testing. */ +/** Strip ANSI escape codes and color tags for content testing. */ function stripFormatting(s: string): string { - return stripAnsi(s); + return stripAnsi(s).replace(/<\/?[a-z]+>/g, ""); } // Arbitraries diff --git a/test/lib/formatters/human.test.ts b/test/lib/formatters/human.test.ts index b0779c79..ad0740ab 100644 --- a/test/lib/formatters/human.test.ts +++ b/test/lib/formatters/human.test.ts @@ -7,7 +7,6 @@ */ import { describe, expect, test } from "bun:test"; -import chalk from "chalk"; import { formatShortId, formatUserIdentity, @@ -22,10 +21,14 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, ""); } -/** Strip ANSI escape codes for content testing. */ +/** Strip ANSI escape codes and color tags for content testing. */ function stripFormatting(s: string): string { - // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars - return s.replace(/\x1b\[[0-9;]*m/g, ""); + return ( + s + .replace(/<\/?[a-z]+>/g, "") + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + .replace(/\x1b\[[0-9;]*m/g, "") + ); } describe("formatShortId edge cases", () => { @@ -54,10 +57,8 @@ describe("formatShortId formatting", () => { test("single project mode applies formatting to suffix", () => { const result = formatShortId("CRAFT-G", { projectSlug: "craft" }); expect(stripFormatting(result)).toBe("CRAFT-G"); - // Suffix should be bold+underlined when color is active - if (chalk.level > 0) { - expect(result).not.toBe(stripFormatting(result)); - } + // Suffix should be wrapped in bold+underline tag + expect(result).toContain("G"); }); test("multi-project mode applies formatting to suffix", () => { @@ -67,10 +68,9 @@ describe("formatShortId formatting", () => { isMultiProject: true, }); expect(stripFormatting(result)).toBe("SPOTLIGHT-ELECTRON-4Y"); - // Alias char and suffix should be bold+underlined when color is active - if (chalk.level > 0) { - expect(result).not.toBe(stripFormatting(result)); - } + // Alias char and suffix should be bold+underlined + expect(result).toContain("E"); + expect(result).toContain("4Y"); }); test("no formatting when no options provided", () => { From 7f352612aaab7ba412413dd2b695a5045a9ddbf0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 22:36:26 +0000 Subject: [PATCH 47/52] fix(formatters): escape mdKvTable values, remove dead trace streaming code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Escape user-facing values (org.name, project.name, assignee name, release versions) with escapeMarkdownInline() before passing to mdKvTable, as its contract requires callers to handle content escaping. Remove unused createTraceStreamingTable, TRACE_HINT_ROWS, and StreamingTableOptions import — trace list has no streaming mode. --- src/lib/formatters/human.ts | 17 +++++++++------ src/lib/formatters/trace.ts | 43 ++----------------------------------- 2 files changed, 12 insertions(+), 48 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index f75ff931..34f02c78 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -527,20 +527,23 @@ export function formatIssueDetails(issue: SentryIssue): string { kvRows.push(["Level", levelLine]); kvRows.push(["Platform", issue.platform ?? "unknown"]); kvRows.push(["Type", issue.type ?? "unknown"]); - kvRows.push(["Assignee", String(issue.assignedTo?.name ?? "Unassigned")]); + kvRows.push([ + "Assignee", + escapeMarkdownInline(String(issue.assignedTo?.name ?? "Unassigned")), + ]); if (issue.project) { kvRows.push([ "Project", - `${issue.project.name ?? "(unknown)"} (${safeCodeSpan(issue.project.slug ?? "")})`, + `${escapeMarkdownInline(issue.project.name ?? "(unknown)")} (${safeCodeSpan(issue.project.slug ?? "")})`, ]); } const firstReleaseVersion = issue.firstRelease?.shortVersion; const lastReleaseVersion = issue.lastRelease?.shortVersion; if (firstReleaseVersion || lastReleaseVersion) { - const first = String(firstReleaseVersion ?? ""); - const last = String(lastReleaseVersion ?? ""); + const first = escapeMarkdownInline(String(firstReleaseVersion ?? "")); + const last = escapeMarkdownInline(String(lastReleaseVersion ?? "")); if (firstReleaseVersion && lastReleaseVersion) { if (firstReleaseVersion === lastReleaseVersion) { kvRows.push(["Release", first]); @@ -1160,7 +1163,7 @@ export function formatOrgDetails(org: SentryOrganization): string { const kvRows: [string, string][] = []; kvRows.push(["Slug", `\`${org.slug || "(none)"}\``]); - kvRows.push(["Name", org.name || "(unnamed)"]); + kvRows.push(["Name", escapeMarkdownInline(org.name || "(unnamed)")]); kvRows.push(["ID", String(org.id)]); if (org.dateCreated) { kvRows.push(["Created", new Date(org.dateCreated).toLocaleString()]); @@ -1198,7 +1201,7 @@ export function formatProjectDetails( const kvRows: [string, string][] = []; kvRows.push(["Slug", `\`${project.slug || "(none)"}\``]); - kvRows.push(["Name", project.name || "(unnamed)"]); + kvRows.push(["Name", escapeMarkdownInline(project.name || "(unnamed)")]); kvRows.push(["ID", String(project.id)]); kvRows.push(["Platform", project.platform || "Not set"]); kvRows.push(["DSN", `\`${dsn || "No DSN available"}\``]); @@ -1209,7 +1212,7 @@ export function formatProjectDetails( if (project.organization) { kvRows.push([ "Organization", - `${project.organization.name} (${safeCodeSpan(project.organization.slug)})`, + `${escapeMarkdownInline(project.organization.name)} (${safeCodeSpan(project.organization.slug)})`, ]); } if (project.firstEvent) { diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 2ab3e981..12b355d1 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -16,11 +16,7 @@ import { renderMarkdown, stripColorTags, } from "./markdown.js"; -import { - renderTextTable, - StreamingTable, - type StreamingTableOptions, -} from "./text-table.js"; +import { renderTextTable } from "./text-table.js"; /** * Format a duration in milliseconds to a human-readable string. @@ -96,7 +92,7 @@ export function formatTraceRow(item: TransactionListItem): string { * * Emits a proper markdown table header + separator row so that * the streamed rows compose into a valid CommonMark document when redirected. - * In TTY mode, use {@link createTraceStreamingTable} instead. + * In TTY mode, use StreamingTable for row-by-row output instead. * * @returns Header string (includes trailing newline) */ @@ -104,41 +100,6 @@ export function formatTracesHeader(): string { return `${mdTableHeader(TRACE_TABLE_COLS)}\n`; } -/** Hint rows for column width estimation in streaming mode. */ -const TRACE_HINT_ROWS: string[][] = [ - [ - "`abcdef1234567890abcdef1234567890`", - "GET /api/v1/some-endpoint", - "1.23s", - "2 hours ago", - ], -]; - -/** - * Create a StreamingTable configured for trace output. - * - * @param options - Override default table options - * @returns A StreamingTable with trace-specific column configuration - */ -export function createTraceStreamingTable( - options: Partial = {} -): StreamingTable { - const alignments = TRACE_TABLE_COLS.map((c) => - c.endsWith(":") ? ("right" as const) : null - ); - const names = TRACE_TABLE_COLS.map((c) => - c.endsWith(":") ? c.slice(0, -1) : c - ); - return new StreamingTable(names, { - hintRows: TRACE_HINT_ROWS, - alignments, - // Trace ID and Duration/When are fixed; Transaction gets the rest - shrinkable: [false, true, false, false], - truncate: true, - ...options, - }); -} - /** * Build a rendered markdown table for a batch list of trace transactions. * From 171410fb069dd4b8c073a663508a5376bdc139db Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 22:51:51 +0000 Subject: [PATCH 48/52] fix(formatters): render color tags in streaming log cells, hoist regex Apply renderInlineMarkdown() to each cell in the log streaming path so that color tags (ERROR) and code spans are rendered to ANSI instead of appearing as literal text. Hoist the COLOR_TAG_RE regex to module scope so stripColorTags() doesn't recompile it on every invocation. --- src/commands/log/list.ts | 3 ++- src/lib/formatters/markdown.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index f5843ff3..8027e470 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -21,6 +21,7 @@ import { writeFooter, writeJson, } from "../../lib/formatters/index.js"; +import { renderInlineMarkdown } from "../../lib/formatters/markdown.js"; import { buildListCommand, TARGET_PATTERN_NOTE, @@ -93,7 +94,7 @@ function writeLogs( } } else if (table) { for (const log of logs) { - stdout.write(table.row(buildLogRowCells(log))); + stdout.write(table.row(buildLogRowCells(log).map(renderInlineMarkdown))); } } else { for (const log of logs) { diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 58213aa7..1f3f85f1 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -208,6 +208,12 @@ const COLOR_TAGS: Record string> = { bu: (t) => chalk.bold.underline(t), }; +/** Regex matching all supported color tag pairs — compiled once at module scope. */ +const COLOR_TAG_RE = new RegExp( + `<(${Object.keys(COLOR_TAGS).join("|")})>([\\s\\S]*?)<\\/\\1>`, + "gi" +); + /** * Wrap text in a semantic color tag for use in markdown strings. * @@ -237,13 +243,12 @@ const RE_SELF_TAG = /^<([a-z]+)>([\s\S]*?)<\/\1>$/i; export function stripColorTags(text: string): string { // Repeatedly replace all supported color tag pairs until none remain. // The loop handles nested tags (uncommon but possible). - const tagPattern = Object.keys(COLOR_TAGS).join("|"); - const re = new RegExp(`<(${tagPattern})>([\\s\\S]*?)<\\/\\1>`, "gi"); let result = text; let prev: string; do { prev = result; - result = result.replace(re, "$2"); + COLOR_TAG_RE.lastIndex = 0; + result = result.replace(COLOR_TAG_RE, "$2"); } while (result !== prev); return result; } From 735b8f8d78615daa63273e1dc132fbd50c414404 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 23:12:10 +0000 Subject: [PATCH 49/52] fix(formatters): prevent double-escaping of issue titles in plain mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove escapeMarkdownInline from the TITLE column value function in writeIssueTable — column values should return raw text, not pre-escaped markdown. Instead, expand escapeMarkdownCell to also escape inline emphasis chars (*, _, `, [, ]) so the plain-mode path in buildMarkdownTable produces safe CommonMark without double-escaping backslashes. --- src/lib/formatters/human.ts | 4 +--- src/lib/formatters/markdown.ts | 7 ++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 34f02c78..3ab80626 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -477,9 +477,7 @@ export function writeIssueTable( }, { header: "TITLE", - // Escape markdown emphasis chars so underscores/asterisks in issue titles - // (e.g. "Expected got ") don't render as italic/bold text. - value: ({ issue }) => escapeMarkdownInline(issue.title), + value: ({ issue }) => issue.title, } ); diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index 1f3f85f1..a18cb80b 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -88,7 +88,12 @@ export function escapeMarkdownCell(value: string): string { .replace(/\\/g, "\\\\") .replace(/\|/g, "\\|") .replace(//g, ">"); + .replace(/>/g, ">") + .replace(/\*/g, "\\*") + .replace(/_/g, "\\_") + .replace(/`/g, "\\`") + .replace(/\[/g, "\\[") + .replace(/]/g, "\\]"); } /** From a65000ab9512426536a5e17d24d1a62ef8db8f96 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 23:28:25 +0000 Subject: [PATCH 50/52] fix(formatters): preserve markdown links in escapeMarkdownCell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert [, ], and backtick escaping from escapeMarkdownCell — these broke intentional markdown links ([SHORT-ID](permalink)) in the issue table's SHORT ID column. Keep only _ and * escaping (for issue titles) alongside the original structural escapes (|, \, <, >). Column values are raw text; escapeMarkdownCell is the single escaping pass in buildMarkdownTable. --- src/lib/formatters/markdown.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index a18cb80b..daf62397 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -90,10 +90,7 @@ export function escapeMarkdownCell(value: string): string { .replace(//g, ">") .replace(/\*/g, "\\*") - .replace(/_/g, "\\_") - .replace(/`/g, "\\`") - .replace(/\[/g, "\\[") - .replace(/]/g, "\\]"); + .replace(/_/g, "\\_"); } /** From 05a2d43478d593b4d2a336a00412de6b5312bc04 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 1 Mar 2026 00:03:16 +0000 Subject: [PATCH 51/52] refactor(formatters): move table cell escaping to column definitions Move escapeMarkdownCell responsibility from buildMarkdownTable to individual column value functions. This preserves intentional markdown (links in SHORT ID, code spans in SLUG) while escaping user data (NAME, TITLE, URL) at the source. Columns that produce markdown-safe content (slugs, enums, counts) don't need escaping. --- src/commands/org/list.ts | 3 ++- src/commands/project/list.ts | 3 ++- src/commands/repo/list.ts | 5 +++-- src/commands/team/list.ts | 3 ++- src/lib/formatters/human.ts | 2 +- src/lib/formatters/markdown.ts | 4 +--- src/lib/formatters/table.ts | 4 ++-- test/lib/formatters/table.test.ts | 7 ++++--- 8 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index df0e30e3..cada4194 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -10,6 +10,7 @@ import { buildCommand } from "../../lib/command.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { getAllOrgRegions } from "../../lib/db/regions.js"; import { writeFooter, writeJson } from "../../lib/formatters/index.js"; +import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { buildListLimitFlag, LIST_JSON_FLAG } from "../../lib/list-command.js"; @@ -108,7 +109,7 @@ export const listCommand = buildCommand({ ...(showRegion ? [{ header: "REGION", value: (r: OrgRow) => r.region ?? "" }] : []), - { header: "NAME", value: (r) => r.name }, + { header: "NAME", value: (r) => escapeMarkdownCell(r.name) }, ]; writeTable(stdout, rows, columns); diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index fbebd253..4fe7ae3a 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -32,6 +32,7 @@ import { } from "../../lib/db/pagination.js"; import { AuthError, ContextError } from "../../lib/errors.js"; import { writeFooter, writeJson } from "../../lib/formatters/index.js"; +import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { buildListCommand, @@ -229,7 +230,7 @@ async function resolveOrgsForAutoDetect(cwd: string): Promise { const PROJECT_COLUMNS: Column[] = [ { header: "ORG", value: (p) => p.orgSlug || "" }, { header: "PROJECT", value: (p) => p.slug }, - { header: "NAME", value: (p) => p.name }, + { header: "NAME", value: (p) => escapeMarkdownCell(p.name) }, { header: "PLATFORM", value: (p) => p.platform || "" }, ]; diff --git a/src/commands/repo/list.ts b/src/commands/repo/list.ts index 374ae6e0..54f2bc6a 100644 --- a/src/commands/repo/list.ts +++ b/src/commands/repo/list.ts @@ -14,6 +14,7 @@ import { listRepositories, listRepositoriesPaginated, } from "../../lib/api-client.js"; +import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { buildOrgListCommand, @@ -31,10 +32,10 @@ type RepositoryWithOrg = SentryRepository & { orgSlug?: string }; /** Column definitions for the repository table. */ const REPO_COLUMNS: Column[] = [ { header: "ORG", value: (r) => r.orgSlug || "" }, - { header: "NAME", value: (r) => r.name }, + { header: "NAME", value: (r) => escapeMarkdownCell(r.name) }, { header: "PROVIDER", value: (r) => r.provider.name }, { header: "STATUS", value: (r) => r.status }, - { header: "URL", value: (r) => r.url || "" }, + { header: "URL", value: (r) => escapeMarkdownCell(r.url || "") }, ]; /** Shared config that plugs into the org-list framework. */ diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts index b9abd3ab..25244c49 100644 --- a/src/commands/team/list.ts +++ b/src/commands/team/list.ts @@ -15,6 +15,7 @@ import { listTeams, listTeamsPaginated, } from "../../lib/api-client.js"; +import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { buildOrgListCommand, @@ -33,7 +34,7 @@ type TeamWithOrg = SentryTeam & { orgSlug?: string }; const TEAM_COLUMNS: Column[] = [ { header: "ORG", value: (t) => t.orgSlug || "" }, { header: "SLUG", value: (t) => t.slug }, - { header: "NAME", value: (t) => t.name }, + { header: "NAME", value: (t) => escapeMarkdownCell(t.name) }, { header: "MEMBERS", value: (t) => String(t.memberCount ?? ""), diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 3ab80626..a4f723ef 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -477,7 +477,7 @@ export function writeIssueTable( }, { header: "TITLE", - value: ({ issue }) => issue.title, + value: ({ issue }) => escapeMarkdownCell(issue.title), } ); diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index daf62397..1f3f85f1 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -88,9 +88,7 @@ export function escapeMarkdownCell(value: string): string { .replace(/\\/g, "\\\\") .replace(/\|/g, "\\|") .replace(//g, ">") - .replace(/\*/g, "\\*") - .replace(/_/g, "\\_"); + .replace(/>/g, ">"); } /** diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts index fb76fef2..b1fb34c8 100644 --- a/src/lib/formatters/table.ts +++ b/src/lib/formatters/table.ts @@ -41,7 +41,7 @@ export type Column = { /** * Build a raw CommonMark table string from items and column definitions. * - * Cell values are escaped via {@link escapeMarkdownCell} so pipe and + * Column value functions should call {@link escapeMarkdownCell} on user data so pipe and * backslash characters in API-supplied strings don't break the table. * * Used for plain/non-TTY output mode. @@ -55,7 +55,7 @@ export function buildMarkdownTable( const rows = items .map( (item) => - `| ${columns.map((c) => escapeMarkdownCell(stripColorTags(c.value(item)))).join(" | ")} |` + `| ${columns.map((c) => stripColorTags(c.value(item))).join(" | ")} |` ) .join("\n"); return `${header}\n${separator}\n${rows}`; diff --git a/test/lib/formatters/table.test.ts b/test/lib/formatters/table.test.ts index 089afe05..dafa444b 100644 --- a/test/lib/formatters/table.test.ts +++ b/test/lib/formatters/table.test.ts @@ -6,6 +6,7 @@ */ import { describe, expect, mock, test } from "bun:test"; +import { escapeMarkdownCell } from "../../../src/lib/formatters/markdown.js"; import { type Column, writeTable } from "../../../src/lib/formatters/table.js"; type Row = { name: string; count: number; status: string }; @@ -137,15 +138,15 @@ describe("writeTable (plain mode)", () => { }); }); - test("escapes pipe characters in cell values", () => { + test("escapes pipe characters when column uses escapeMarkdownCell", () => { withPlain(() => { const cols: Column<{ v: string }>[] = [ - { header: "VAL", value: (r) => r.v }, + { header: "VAL", value: (r) => escapeMarkdownCell(r.v) }, ]; const write = mock(() => true); writeTable({ write }, [{ v: "a|b" }], cols); const output = write.mock.calls.map((c) => c[0]).join(""); - // Pipe should be escaped + // Pipe should be escaped by the column value function expect(output).toContain("a\\|b"); }); }); From 9b3188f9e339daa1b42d61be265893d8349fa5fb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 1 Mar 2026 08:28:07 +0000 Subject: [PATCH 52/52] fix(formatters): use colorTag for muted dash, escapeMarkdownInline for titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace muted("—") with colorTag("muted", "—") in formatRelativeTime so stripColorTags handles it in plain mode — raw ANSI from chalk leaks through since stripColorTags only strips pairs. Use escapeMarkdownInline for issue titles instead of escapeMarkdownCell to escape _, *, and backticks — prevents __init__ rendering as emphasis in TTY mode. --- src/lib/formatters/human.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index a4f723ef..be0c9900 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -218,7 +218,7 @@ export function formatStatusLabel(status: string | undefined): string { */ export function formatRelativeTime(dateString: string | undefined): string { if (!dateString) { - return muted("—"); + return colorTag("muted", "—"); } const date = new Date(dateString); @@ -477,7 +477,7 @@ export function writeIssueTable( }, { header: "TITLE", - value: ({ issue }) => escapeMarkdownCell(issue.title), + value: ({ issue }) => escapeMarkdownInline(issue.title), } );