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 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:57 +02:00
parent f9d6c4f0c3
commit 4e5d0dcbd6
2 changed files with 95 additions and 10 deletions
+53 -10
View File
@@ -1,17 +1,50 @@
/** /**
* @fileoverview Renders ToolResult[] from the dependency checker into a * @fileoverview Renders ToolResult[] from the dependency checker into a
* human-readable grouped table or JSON, and computes the process exit code. * 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 * Plain text by default (no color) so output is stable and snapshot-friendly;
* CLI layer may colorize. * 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 * @module utils/dependency-report
*/ */
import { columnWidths, padStyled } from '../cli-style.js';
import type { ProbeEnvironment, ToolCategory } from '../config/dependency-registry.js'; import type { ProbeEnvironment, ToolCategory } from '../config/dependency-registry.js';
import type { ToolResult, ToolStatus } from './dependency-checker.js'; import type { ToolResult, ToolStatus } from './dependency-checker.js';
const CATEGORY_ORDER: ToolCategory[] = ['core', 'office', 'other']; 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 { function glyph(r: ToolResult): string {
if (r.status === 'ok') return '✓'; if (r.status === 'ok') return '✓';
if (r.status === 'skipped') return '○'; if (r.status === 'skipped') return '○';
@@ -33,24 +66,34 @@ export function computeExitCode(results: ToolResult[]): number {
return failed ? 1 : 0; return failed ? 1 : 0;
} }
export function renderTable(results: ToolResult[], environment: ProbeEnvironment): string { export function renderTable(
const lines: string[] = [`Codeman dependency check — ${environment}`, '']; 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) { for (const category of CATEGORY_ORDER) {
const rows = results.filter((r) => r.category === category); const rows = results.filter((r) => r.category === category);
if (rows.length === 0) continue; if (rows.length === 0) continue;
lines.push(category.toUpperCase()); lines.push(style.heading(category.toUpperCase()));
for (const r of rows) { for (const r of rows) {
const detail = r.path ? ` ${r.path}` : ''; const label = padStyled(r.label, labelWidth ?? 0, style.label);
lines.push(` ${glyph(r)} ${r.label.padEnd(14)} ${statusText(r).padEnd(22)}${detail}`); const status = padStyled(statusText(r), statusWidth ?? 0, (text) => style.status(r, text));
if (r.usedBy.length) lines.push(` used by: ${r.usedBy.join(', ')}`); const detail = r.path ? ` ${style.path(r.path)}` : '';
if (r.installHint) lines.push(` install: ${r.installHint}`); 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(''); lines.push('');
} }
const ok = results.filter((r) => r.status === 'ok').length; const ok = results.filter((r) => r.status === 'ok').length;
const requiredMissing = results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').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; 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'); return lines.join('\n');
} }
+42
View File
@@ -61,6 +61,48 @@ describe('renderTable', () => {
expect(out).toContain('document preview'); expect(out).toContain('document preview');
expect(out).toContain('sudo apt install tmux'); 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', () => { describe('renderJson', () => {