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', () => {