diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 506af80..4152a1d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -12,5 +12,5 @@ "bracketSameLine": false, "bracketSpacing": true, "sortImports": true, - "ignorePatterns": ["**/dist/**", "**/coverage/**"] + "ignorePatterns": ["**/dist/**", "**/coverage/**", "**/CHANGELOG.md"] } diff --git a/src/checks/external.ts b/src/checks/external.ts index 5b4b8b0..9baec11 100644 --- a/src/checks/external.ts +++ b/src/checks/external.ts @@ -39,6 +39,8 @@ export type ExternalCheckSpec = { docs?: string /** Extra guard beyond bin presence (e.g. require a tsconfig). */ canRun?: () => boolean + /** Rewrite the tool's captured output before it is printed, e.g. to strip a tool's own hardcoded colouring. */ + transformOutput?: (output: string) => string /** * Default trailing args scaffolded after `--` (e.g. skott's `src/*.ts` target), surfaced in the `verify:` * script so a consumer can see and tweak them. Not baked into `runDefault`; only the scaffolded script carries them. @@ -90,7 +92,7 @@ export function defineExternalCheck(spec: ExternalCheckSpec): Check { } const command = appendArgs(selectCommand(spec, resolveMode()), extraArgs) // quiet: buffer the tool's output and flush only on failure (streamed live under --verbose). - const code = await runCommand(command, { env: envWithLocalBin(), quiet: true }) + const code = await runCommand(command, { env: envWithLocalBin(), quiet: true, transform: spec.transformOutput }) // Only on failure — passing runs stay silent to save tokens. if (code !== 0) console.error(color.dim(externalFailureHint(spec, command))) return { name: spec.name, ok: code === 0 } diff --git a/src/checks/registry.ts b/src/checks/registry.ts index 0083f94..b1638c0 100644 --- a/src/checks/registry.ts +++ b/src/checks/registry.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' +import { withoutRed } from '../shared/color.ts' import { runComments } from './comments.ts' import { runComplexity } from './complexity.ts' import { defineExternalCheck } from './external.ts' @@ -85,6 +86,9 @@ export const CHECKS: Check[] = [ bin: 'jscpd', checkCommand: 'jscpd --format typescript,tsx --exit-code 1 --ignore "**/*.test.*" -r consoleFull src', devDeps: ['jscpd'], + // jscpd hardcodes red header cells in its stats table (even on a clean run) — reads like a failure. It ignores + // NO_COLOR/FORCE_COLOR, so strip the red foreground from its output; the table renders in the default colour. + transformOutput: withoutRed, docs: 'https://github.com/kucherenko/jscpd/tree/master/apps/jscpd#config', }), ] diff --git a/src/orchestrator/runAll.ts b/src/orchestrator/runAll.ts index 3b08c02..07ff7dd 100644 --- a/src/orchestrator/runAll.ts +++ b/src/orchestrator/runAll.ts @@ -1,5 +1,5 @@ import { CHECKS } from '../checks/registry.ts' -import { color } from '../shared/color.ts' +import { color, paintRed } from '../shared/color.ts' import { resolveMode } from '../shared/mode.ts' import { installConsoleCapture, runCaptured } from '../shared/output.ts' import { runCommand } from '../shared/spawn.ts' @@ -92,6 +92,15 @@ export async function runAll(opts: RunAllOptions = {}): Promise { if (run.output) process.stdout.write(run.output) } + // Under --verbose every check is printed, so a single failure gets buried mid-list; re-print the + // failing checks' output at the end in red so the step that actually failed stands out. + if (loud) { + for (const run of runs.filter((r) => !r.ok)) { + console.log(color.red(color.heading(`\n▶ ${run.name} (failed)`))) + if (run.output) process.stdout.write(paintRed(run.output)) + } + } + if (opts.measure) { const records: MeasureRecord[] = runs.map((r) => ({ script: r.name, code: r.ok ? 0 : 1, durationMs: r.durationMs })) printMeasureTable(records, Date.now() - startTime) diff --git a/src/shared/color.test.ts b/src/shared/color.test.ts new file mode 100644 index 0000000..2539984 --- /dev/null +++ b/src/shared/color.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { paintRed, withoutRed } from './color.ts' + +describe('withoutRed', () => { + it('drops red-foreground codes so text renders in the default colour', () => { + expect(withoutRed('\x1b[31mheader\x1b[39m')).toBe('header\x1b[39m') + }) + + it('leaves other colours (e.g. a tool table border) untouched', () => { + expect(withoutRed('\x1b[90m│\x1b[39m\x1b[31m cell \x1b[39m')).toBe('\x1b[90m│\x1b[39m cell \x1b[39m') + }) +}) + +describe('paintRed', () => { + it('wraps the text in red', () => { + expect(paintRed('boom')).toBe('\x1b[31mboom\x1b[39m') + }) + + it("re-asserts red after an embedded foreground reset so the highlight isn't cancelled partway", () => { + expect(paintRed('a\x1b[39mb')).toBe('\x1b[31ma\x1b[31mb\x1b[39m') + }) +}) diff --git a/src/shared/color.ts b/src/shared/color.ts index 6e52610..9bc1112 100644 --- a/src/shared/color.ts +++ b/src/shared/color.ts @@ -8,3 +8,16 @@ export const color = { bold: (s: string | number) => `\x1b[1m${s}\x1b[22m`, heading: (s: string | number) => `\x1b[1m\x1b[4m${s}\x1b[24m\x1b[22m`, } + +/** + * Paint a whole block red, re-asserting red after any foreground-colour reset the text already contains + * (e.g. a tool's own ANSI codes) so the highlight survives to the end instead of being cancelled partway. + */ +export function paintRed(text: string): string { + return `\x1b[31m${text.replaceAll('\x1b[39m', '\x1b[31m')}\x1b[39m` +} + +/** Drop red-foreground codes from text so a tool's hardcoded red renders in the terminal's default colour. */ +export function withoutRed(text: string): string { + return text.replaceAll('\x1b[31m', '') +} diff --git a/src/shared/spawn.ts b/src/shared/spawn.ts index 09783e8..988a1bd 100644 --- a/src/shared/spawn.ts +++ b/src/shared/spawn.ts @@ -22,6 +22,8 @@ export type RunCommandOptions = { cwd?: string env?: Record quiet?: boolean + /** Rewrite the command's buffered output before it is emitted (only applies when output is suppressed/buffered). */ + transform?: (output: string) => string } /** @@ -45,7 +47,10 @@ export function runCommand(command: string, opts: RunCommandOptions = {}): Promi child.on('close', (code) => { const exitCode = code ?? 1 // When capturing (parallel `verifyx all`), hand all output to the buffer; otherwise flush only on failure. - if (suppress && chunks.length > 0 && (isCapturing() || exitCode !== 0)) emit(Buffer.concat(chunks).toString()) + if (suppress && chunks.length > 0 && (isCapturing() || exitCode !== 0)) { + const out = Buffer.concat(chunks).toString() + emit(opts.transform ? opts.transform(out) : out) + } resolve(exitCode) }) child.on('error', () => resolve(127))