Skip to content

Commit db30355

Browse files
committed
feat(formatters): add semantic HTML color tags for log levels and issue status
Replace direct chalk/ANSI color calls in markdown-emitting contexts with semantic <tag>text</tag> color tags handled by the custom renderer. - Add COLOR_TAGS map and colorTag() helper to markdown.ts - renderInline() now handles html tokens: <red>text</red> → 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 <tag> markers alongside ANSI in content assertions
1 parent dc0da0a commit db30355

File tree

5 files changed

+138
-52
lines changed

5 files changed

+138
-52
lines changed

AGENTS.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -664,8 +664,10 @@ mock.module("./some-module", () => ({
664664
665665
### Gotcha
666666
667-
<!-- lore:019c9f03-70cc-7e04-9835-e5c21f3d4e7d -->
668-
* **Craft npm target requires tarball artifact — use github-only target for OIDC npm publish**: Craft's npm target expects a pre-built tarball (from \`npm pack\`) uploaded as a GitHub Actions artifact, and authenticates via \`NPM\_TOKEN\` secret. This is incompatible with npm OIDC trusted publishing (\`--provenance\`), which requires \`id-token: write\` permission and \`setup-node\` with \`registry-url\` — no token needed. Workaround: configure \`.craft.yml\` with only \`github\` target and an explicit \`preReleaseCommand\` for version bumping (e.g., \`npm version $CRAFT\_NEW\_VERSION --no-git-tag-version\`), then run \`npm publish --provenance --access public\` as a separate step after \`craft publish\`. Without the npm target, Craft's auto version bump won't touch package.json, so the preReleaseCommand is essential.
667+
<!-- lore:019c9f57-aa13-7ab1-8f2a-e5c9e8df1e81 -->
668+
* **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 \<package> 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.
669+
<!-- lore:019c9f57-aa0c-7a2a-8a10-911b13b48fc0 -->
670+
* **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.
669671
<!-- lore:019c9ee7-f55a-7ab0-9f4a-4147b5f535c2 -->
670672
* **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 \<pkg>\` to verify each override still has consumers in the resolved tree. Remove any that return empty results.
671673
<!-- lore:019c9eb7-a648-70f7-8a8d-5fc5b5c0f221 -->
@@ -716,6 +718,8 @@ mock.module("./some-module", () => ({
716718
* **@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.
717719
<!-- lore:4729229d-36b9-4118-b90b-ea8151e6928f -->
718720
* **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.
721+
<!-- lore:019c9f03-70cc-7e04-9835-e5c21f3d4e7d -->
722+
* **Craft npm target requires tarball artifact — use github-only target for OIDC npm publish**: Craft's npm target now supports OIDC trusted publishing with auto-detection. Three modes: 1. \*\*Auto-detect (zero config):\*\* If \`NPM\_TOKEN\` is absent but OIDC env vars are detected (\`ACTIONS\_ID\_TOKEN\_REQUEST\_URL\` + \`ACTIONS\_ID\_TOKEN\_REQUEST\_TOKEN\` for GitHub Actions, or \`NPM\_ID\_TOKEN\` for GitLab CI), and npm >= 11.5.1 is available, Craft automatically uses OIDC for publishing. 2. \*\*Explicit \`oidc: true\`:\*\* Forces OIDC mode even when \`NPM\_TOKEN\` is set. Hard-errors if npm < 11.5.1 or only yarn is available. 3. \*\*Token auth (default):\*\* When \`NPM\_TOKEN\` is set and no \`oidc: true\`, existing token-based behavior is unchanged. Key implementation details: - OIDC publish path skips the temp \`.npmrc\` file and \`npm\_config\_userconfig\` override entirely, letting npm's native OIDC auto-detection handle auth. - \`getLatestVersion()\` (uses \`npm info\`) still uses token auth if a token is available, since OIDC only works for \`npm publish\`. Without a token, it runs unauthenticated (works for public packages). - For \`checkPackageName\` on private packages in OIDC mode without \`NPM\_TOKEN\`: warns and skips the version check rather than erroring. - OIDC requires npm (not yarn) — hard error if yarn-only with \`oidc: true\`. - \`NpmTargetOptions.token\` is now optional (\`string | undefined\`), and \`useOidc: boolean\` was added. CI workflow requirements for OIDC: \`id-token: write\` permission, \`setup-node\` with \`registry-url: 'https://registry.npmjs.org'\`, npm >= 11.5.1, Node >= 22.14.0.
719723
<!-- lore:019c9556-e369-791d-8fad-01be6aa3633a -->
720724
* **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\` in \`.craft.yml\` when using targets that don't support auto-bump. For npm projects that handle publishing separately (e.g., OIDC trusted publishing), use \`preReleaseCommand: npm version $CRAFT\_NEW\_VERSION --no-git-tag-version\`.
721725
<!-- lore:019c99d5-69f8-716b-8908-a42537cc5a83 -->

src/lib/formatters/human.ts

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,35 +25,47 @@ import type {
2525
Writer,
2626
} from "../../types/index.js";
2727
import { withSerializeSpan } from "../telemetry.js";
28+
import { type FixabilityTier, muted } from "./colors.js";
2829
import {
29-
type FixabilityTier,
30-
fixabilityColor,
31-
green,
32-
levelColor,
33-
muted,
34-
statusColor,
35-
yellow,
36-
} from "./colors.js";
37-
import {
30+
colorTag,
3831
escapeMarkdownCell,
3932
escapeMarkdownInline,
4033
renderMarkdown,
4134
safeCodeSpan,
4235
} from "./markdown.js";
4336
import { type Column, writeTable } from "./table.js";
4437

38+
// Color tag maps
39+
40+
/** Markdown color tags for issue level values */
41+
const LEVEL_TAGS: Record<string, Parameters<typeof colorTag>[0]> = {
42+
fatal: "red",
43+
error: "red",
44+
warning: "yellow",
45+
info: "cyan",
46+
debug: "muted",
47+
};
48+
49+
/** Markdown color tags for Seer fixability tiers */
50+
const FIXABILITY_TAGS: Record<FixabilityTier, Parameters<typeof colorTag>[0]> =
51+
{
52+
high: "green",
53+
med: "yellow",
54+
low: "red",
55+
};
56+
4557
// Status Formatting
4658

4759
const STATUS_ICONS: Record<IssueStatus, string> = {
48-
resolved: green("✓"),
49-
unresolved: yellow("●"),
50-
ignored: muted("−"),
60+
resolved: colorTag("green", "✓"),
61+
unresolved: colorTag("yellow", "●"),
62+
ignored: colorTag("muted", "−"),
5163
};
5264

5365
const STATUS_LABELS: Record<IssueStatus, string> = {
54-
resolved: `${green("✓")} Resolved`,
55-
unresolved: `${yellow("●")} Unresolved`,
56-
ignored: `${muted("−")} Ignored`,
66+
resolved: `${colorTag("green", "✓")} Resolved`,
67+
unresolved: `${colorTag("yellow", "●")} Unresolved`,
68+
ignored: `${colorTag("muted", "−")} Ignored`,
5769
};
5870

5971
/** Maximum features to display before truncating with "... and N more" */
@@ -179,22 +191,15 @@ function formatFeaturesMarkdown(features: string[] | undefined): string {
179191
* Get status icon for an issue status
180192
*/
181193
export function formatStatusIcon(status: string | undefined): string {
182-
if (!status) {
183-
return statusColor("●", status);
184-
}
185-
return STATUS_ICONS[status as IssueStatus] ?? statusColor("●", status);
194+
return STATUS_ICONS[status as IssueStatus] ?? colorTag("yellow", "●");
186195
}
187196

188197
/**
189198
* Get full status label for an issue status
190199
*/
191200
export function formatStatusLabel(status: string | undefined): string {
192-
if (!status) {
193-
return `${statusColor("●", status)} Unknown`;
194-
}
195201
return (
196-
STATUS_LABELS[status as IssueStatus] ??
197-
`${statusColor("●", status)} Unknown`
202+
STATUS_LABELS[status as IssueStatus] ?? `${colorTag("yellow", "●")} Unknown`
198203
);
199204
}
200205

@@ -407,8 +412,12 @@ export function writeIssueTable(
407412
const columns: Column<IssueTableRow>[] = [
408413
{
409414
header: "LEVEL",
410-
value: ({ issue }) =>
411-
levelColor((issue.level ?? "unknown").toUpperCase(), issue.level),
415+
value: ({ issue }) => {
416+
const level = (issue.level ?? "unknown").toLowerCase();
417+
const tag = LEVEL_TAGS[level];
418+
const label = level.toUpperCase();
419+
return tag ? colorTag(tag, label) : label;
420+
},
412421
},
413422
];
414423

@@ -446,7 +455,8 @@ export function writeIssueTable(
446455
const text = formatFixability(issue.seerFixabilityScore);
447456
const score = issue.seerFixabilityScore;
448457
if (text && score !== null && score !== undefined) {
449-
return fixabilityColor(text, getSeerFixabilityLabel(score));
458+
const tier = getSeerFixabilityLabel(score);
459+
return colorTag(FIXABILITY_TAGS[tier], text);
450460
}
451461
return "";
452462
},
@@ -490,7 +500,9 @@ export function formatIssueDetails(issue: SentryIssue): string {
490500
) {
491501
const tier = getSeerFixabilityLabel(issue.seerFixabilityScore);
492502
const fixDetail = formatFixabilityDetail(issue.seerFixabilityScore);
493-
rows.push(`| **Fixability** | ${fixabilityColor(fixDetail, tier)} |`);
503+
rows.push(
504+
`| **Fixability** | ${colorTag(FIXABILITY_TAGS[tier], fixDetail)} |`
505+
);
494506
}
495507

496508
let levelLine = issue.level ?? "unknown";

src/lib/formatters/log.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
import type { DetailedSentryLog, SentryLog } from "../../types/index.js";
88
import { buildTraceUrl } from "../sentry-urls.js";
9-
import { cyan, muted, red, yellow } from "./colors.js";
109
import {
10+
colorTag,
1111
divider,
1212
escapeMarkdownCell,
1313
escapeMarkdownInline,
@@ -17,31 +17,32 @@ import {
1717
renderMarkdown,
1818
} from "./markdown.js";
1919

20-
/** Color functions for log severity levels */
21-
const SEVERITY_COLORS: Record<string, (text: string) => string> = {
22-
fatal: red,
23-
error: red,
24-
warning: yellow,
25-
warn: yellow,
26-
info: cyan,
27-
debug: muted,
28-
trace: muted,
20+
/** Markdown color tag names for log severity levels */
21+
const SEVERITY_TAGS: Record<string, string> = {
22+
fatal: "red",
23+
error: "red",
24+
warning: "yellow",
25+
warn: "yellow",
26+
info: "cyan",
27+
debug: "muted",
28+
trace: "muted",
2929
};
3030

3131
/** Column headers for the streaming log table */
3232
const LOG_TABLE_COLS = ["Timestamp", "Level", "Message"] as const;
3333

3434
/**
35-
* Format severity level with appropriate color.
35+
* Format severity level with appropriate color tag.
3636
* Pads to 7 characters for alignment (longest: "warning").
3737
*
3838
* @param severity - The log severity level
39-
* @returns Colored and padded severity string
39+
* @returns Markdown color-tagged and padded severity string
4040
*/
4141
function formatSeverity(severity: string | null | undefined): string {
4242
const level = (severity ?? "info").toLowerCase();
43-
const colorFn = SEVERITY_COLORS[level] ?? ((s: string) => s);
44-
return colorFn(level.toUpperCase().padEnd(7));
43+
const tag = SEVERITY_TAGS[level];
44+
const label = level.toUpperCase().padEnd(7);
45+
return tag ? colorTag(tag as Parameters<typeof colorTag>[0], label) : label;
4546
}
4647

4748
/**
@@ -118,15 +119,16 @@ export function formatLogTable(logs: SentryLog[]): string {
118119
}
119120

120121
/**
121-
* Format severity level with color for detailed view (not padded).
122+
* Format severity level with color tag for detailed view (not padded).
122123
*
123124
* @param severity - The log severity level
124-
* @returns Colored severity string
125+
* @returns Markdown color-tagged severity string
125126
*/
126127
function formatSeverityLabel(severity: string | null | undefined): string {
127128
const level = (severity ?? "info").toLowerCase();
128-
const colorFn = SEVERITY_COLORS[level] ?? ((s: string) => s);
129-
return colorFn(level.toUpperCase());
129+
const tag = SEVERITY_TAGS[level];
130+
const label = level.toUpperCase();
131+
return tag ? colorTag(tag as Parameters<typeof colorTag>[0], label) : label;
130132
}
131133

132134
/**

src/lib/formatters/markdown.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,78 @@ export function divider(width = 80): string {
171171

172172
/** Sentinel-inspired color palette */
173173
const COLORS = {
174+
red: "#fe4144",
175+
green: "#83da90",
174176
yellow: "#FDB81B",
175177
blue: "#226DFC",
178+
magenta: "#FF45A8",
176179
cyan: "#79B8FF",
177180
muted: "#898294",
178181
} as const;
179182

183+
/**
184+
* Semantic HTML color tags supported in markdown strings.
185+
*
186+
* Formatters can embed `<red>text</red>`, `<green>text</green>`, etc. in
187+
* any markdown string and the custom renderer will apply the corresponding
188+
* ANSI color. In plain (non-TTY) mode the tags are stripped, leaving only
189+
* the inner text.
190+
*
191+
* Supported tags: red, green, yellow, blue, magenta, cyan, muted
192+
*/
193+
const COLOR_TAGS: Record<string, (text: string) => string> = {
194+
red: (t) => chalk.hex(COLORS.red)(t),
195+
green: (t) => chalk.hex(COLORS.green)(t),
196+
yellow: (t) => chalk.hex(COLORS.yellow)(t),
197+
blue: (t) => chalk.hex(COLORS.blue)(t),
198+
magenta: (t) => chalk.hex(COLORS.magenta)(t),
199+
cyan: (t) => chalk.hex(COLORS.cyan)(t),
200+
muted: (t) => chalk.hex(COLORS.muted)(t),
201+
};
202+
203+
/**
204+
* Wrap text in a semantic color tag for use in markdown strings.
205+
*
206+
* In TTY mode the tag is rendered as an ANSI color by the custom renderer.
207+
* In plain mode the tag is stripped and only the inner text is emitted.
208+
*
209+
* @example
210+
* colorTag("red", "ERROR") // → "<red>ERROR</red>"
211+
* colorTag("green", "✓") // → "<green>✓</green>"
212+
*/
213+
export function colorTag(tag: keyof typeof COLOR_TAGS, text: string): string {
214+
return `<${tag}>${text}</${tag}>`;
215+
}
216+
217+
// Pre-compiled regexes for HTML color tag parsing (module-level for performance)
218+
const RE_OPEN_TAG = /^<([a-z]+)>$/i;
219+
const RE_CLOSE_TAG = /^<\/([a-z]+)>$/i;
220+
const RE_SELF_TAG = /^<([a-z]+)>([\s\S]*?)<\/\1>$/i;
221+
222+
/**
223+
* Render an inline HTML token as a color-tagged string.
224+
*
225+
* Handles self-contained `<tag>text</tag>` forms. Bare open/close
226+
* tags are dropped (marked emits them as separate tokens; the
227+
* self-contained form is produced by `colorTag()`).
228+
*/
229+
function renderHtmlToken(raw: string): string {
230+
const trimmed = raw.trim();
231+
if (RE_OPEN_TAG.test(trimmed) || RE_CLOSE_TAG.test(trimmed)) {
232+
return "";
233+
}
234+
const m = RE_SELF_TAG.exec(trimmed);
235+
if (m) {
236+
const tagName = m[1];
237+
const inner = m[2];
238+
if (tagName !== undefined && inner !== undefined) {
239+
const colorFn = COLOR_TAGS[tagName.toLowerCase()];
240+
return colorFn ? colorFn(inner) : inner;
241+
}
242+
}
243+
return "";
244+
}
245+
180246
/**
181247
* Syntax-highlight a code block. Falls back to uniform yellow if the
182248
* language is unknown or highlighting fails.
@@ -243,8 +309,10 @@ function renderInline(tokens: Token[]): string {
243309
return renderInline((token as Tokens.Text).tokens ?? []);
244310
}
245311
return (token as Tokens.Text).text;
246-
case "html":
247-
return ""; // Strip inline HTML
312+
case "html": {
313+
const raw = (token as Tokens.HTML).raw ?? (token as Tokens.HTML).text;
314+
return renderHtmlToken(raw);
315+
}
248316
default:
249317
return (token as { raw?: string }).raw ?? "";
250318
}

test/lib/formatters/human.utils.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ import {
2424
} from "../../../src/lib/formatters/human.js";
2525
import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js";
2626

27-
// Helper to strip ANSI codes for content testing
27+
// Helper to strip ANSI codes and markdown color tags for content testing
2828
function stripAnsi(str: string): string {
2929
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars
30-
return str.replace(/\x1b\[[0-9;]*m/g, "");
30+
return str.replace(/\x1b\[[0-9;]*m/g, "").replace(/<\/?[a-z]+>/g, "");
3131
}
3232

3333
// Status Formatting

0 commit comments

Comments
 (0)