From 4e5d0dcbd603d46b0c833efefe26ebb4749df263 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:23:36 +0200 Subject: [PATCH] fix: measure the doctor table columns and let the CLI paint them "Antigravity CLI" is 15 characters and the hardcoded padEnd(14) pushed that whole row one column right. Widths now come from the widest cell. The header always said the CLI layer may colorize, but there was no way to: renderTable now takes an optional ReportStyle whose hooks are identity by default, so the module still decides nothing about color and its output stays byte-stable. Padding is applied outside the paint, so a row with no path detail ends at its status text instead of trailing spaces inside a color run. Co-Authored-By: Claude Fable 5 --- src/utils/dependency-report.ts | 63 ++++++++++++++++++++++++++++------ test/dependency-report.test.ts | 42 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/utils/dependency-report.ts b/src/utils/dependency-report.ts index 0737322f..f4cd6ca0 100644 --- a/src/utils/dependency-report.ts +++ b/src/utils/dependency-report.ts @@ -1,17 +1,50 @@ /** * @fileoverview Renders ToolResult[] from the dependency checker into a * human-readable grouped table or JSON, and computes the process exit code. - * Plain text only (no color) so output is stable and snapshot-friendly; the - * CLI layer may colorize. + * Plain text by default (no color) so output is stable and snapshot-friendly; + * the CLI layer passes a `ReportStyle` to paint it (see `cli.ts`, `doctor`). + * + * Column widths are measured, not hardcoded: "Antigravity CLI" is 15 characters + * and the old `padEnd(14)` pushed its whole row one column right. * * @module utils/dependency-report */ +import { columnWidths, padStyled } from '../cli-style.js'; import type { ProbeEnvironment, ToolCategory } from '../config/dependency-registry.js'; import type { ToolResult, ToolStatus } from './dependency-checker.js'; const CATEGORY_ORDER: ToolCategory[] = ['core', 'office', 'other']; +/** + * Paint hooks for the CLI layer. Every hook is identity by default, so this + * module never decides anything about color and its output stays byte-stable + * for tests. + */ +export interface ReportStyle { + title(text: string): string; + heading(text: string): string; + glyph(result: ToolResult, glyph: string): string; + label(text: string): string; + status(result: ToolResult, text: string): string; + path(text: string): string; + meta(text: string): string; + summary(text: string): string; +} + +const identity = (text: string): string => text; + +const PLAIN_STYLE: ReportStyle = { + title: identity, + heading: identity, + glyph: (_result, glyph) => glyph, + label: identity, + status: (_result, text) => text, + path: identity, + meta: identity, + summary: identity, +}; + function glyph(r: ToolResult): string { if (r.status === 'ok') return '✓'; if (r.status === 'skipped') return '○'; @@ -33,24 +66,34 @@ export function computeExitCode(results: ToolResult[]): number { return failed ? 1 : 0; } -export function renderTable(results: ToolResult[], environment: ProbeEnvironment): string { - const lines: string[] = [`Codeman dependency check — ${environment}`, '']; +export function renderTable( + results: ToolResult[], + environment: ProbeEnvironment, + style: ReportStyle = PLAIN_STYLE +): string { + // Widths are taken across ALL categories so the groups line up with each other. + const [labelWidth, statusWidth] = columnWidths(results.map((r) => [r.label, statusText(r)])); + const lines: string[] = [style.title(`Codeman dependency check — ${environment}`), '']; for (const category of CATEGORY_ORDER) { const rows = results.filter((r) => r.category === category); if (rows.length === 0) continue; - lines.push(category.toUpperCase()); + lines.push(style.heading(category.toUpperCase())); for (const r of rows) { - const detail = r.path ? ` ${r.path}` : ''; - lines.push(` ${glyph(r)} ${r.label.padEnd(14)} ${statusText(r).padEnd(22)}${detail}`); - if (r.usedBy.length) lines.push(` used by: ${r.usedBy.join(', ')}`); - if (r.installHint) lines.push(` install: ${r.installHint}`); + const label = padStyled(r.label, labelWidth ?? 0, style.label); + const status = padStyled(statusText(r), statusWidth ?? 0, (text) => style.status(r, text)); + const detail = r.path ? ` ${style.path(r.path)}` : ''; + lines.push(` ${style.glyph(r, glyph(r))} ${label} ${status}${detail}`.trimEnd()); + if (r.usedBy.length) lines.push(style.meta(` used by: ${r.usedBy.join(', ')}`)); + if (r.installHint) lines.push(style.meta(` install: ${r.installHint}`)); } lines.push(''); } const ok = results.filter((r) => r.status === 'ok').length; const requiredMissing = results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length; const optionalMissing = results.filter((r) => !r.required && r.status === 'missing').length; - lines.push(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`); + lines.push( + style.summary(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`) + ); return lines.join('\n'); } diff --git a/test/dependency-report.test.ts b/test/dependency-report.test.ts index 813a9497..cc8c1814 100644 --- a/test/dependency-report.test.ts +++ b/test/dependency-report.test.ts @@ -61,6 +61,48 @@ describe('renderTable', () => { expect(out).toContain('document preview'); expect(out).toContain('sudo apt install tmux'); }); + + it('stays color-free unless the caller passes a style', () => { + // eslint-disable-next-line no-control-regex + expect(renderTable(results, 'linux')).not.toMatch(/\x1b\[/); + }); + + it('aligns the status column past a label wider than the old padEnd(14)', () => { + const wide: ToolResult[] = [ + ...results, + { id: 'agy', label: 'Antigravity CLI', category: 'core', required: false, usedBy: [], status: 'missing' }, + ]; + const lines = renderTable(wide, 'linux').split('\n'); + const nodeLine = lines.find((l) => l.includes('Node.js'))!; + const agyLine = lines.find((l) => l.includes('Antigravity CLI'))!; + expect(nodeLine.indexOf('22.22.1')).toBe(agyLine.indexOf('not found')); + }); + + it('applies the caller-supplied paint hooks without shifting the columns', () => { + const plain = renderTable(results, 'linux').split('\n'); + const styled = renderTable(results, 'linux', { + title: (t) => `T{${t}}`, + heading: (t) => `H{${t}}`, + glyph: (_r, g) => `G{${g}}`, + label: (t) => `L{${t}}`, + status: (_r, t) => `S{${t}}`, + path: (t) => `P{${t}}`, + meta: (t) => `M{${t}}`, + summary: (t) => `Z{${t}}`, + }).split('\n'); + expect(styled[0]).toBe(`T{${plain[0]}}`); + expect(styled.find((l) => l.includes('CORE'))).toBe('H{CORE}'); + const nodeLine = styled.find((l) => l.includes('Node.js'))!; + expect(nodeLine).toContain('G{✓}'); + expect(nodeLine).toContain('L{Node.js}'); + expect(nodeLine).toContain('S{22.22.1}'); + expect(nodeLine).toContain('P{/n}'); + expect(styled.some((l) => l.startsWith('M{ used by:'))).toBe(true); + expect(styled[styled.length - 1]).toBe(`Z{${plain[plain.length - 1]}}`); + // Padding lives outside the paint, so a row with no path detail ends at its + // status text rather than trailing invisible spaces inside a color run. + expect(styled.find((l) => l.includes('S{n/a}'))!.endsWith('S{n/a}')).toBe(true); + }); }); describe('renderJson', () => {