Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
[@sashanclrp](https://github.com/sashanclrp) for the original report and
detailed reproduction, and [@sgrimm](https://github.com/sgrimm) for the
decisive wire capture that isolated the actual root cause.
- **CLI**: terminal output no longer mojibakes on Windows PowerShell /
cmd.exe during `codegraph index` and `codegraph sync`. The shimmer
progress renderer writes from a worker thread via `fs.writeSync(1, …)`
to keep the animation smooth while the main thread is busy in SQLite,
which bypasses Node's TTY-aware UTF-8→codepage conversion — so glyphs
like `│ ◆ —` were emitted as raw UTF-8 bytes and reinterpreted as the
console's OEM codepage (CP437, CP936, …), producing strings like
`鋍?[0m 鉒?[0m Scanning files 鈥?N found`. CodeGraph now picks an ASCII
glyph set on Windows by default (`| * -` instead of `│ ◆ —`); set
`CODEGRAPH_UNICODE=1` to opt back into the Unicode glyphs (e.g. on
pwsh 7 with UTF-8 codepage), or `CODEGRAPH_ASCII=1` on any platform to
force ASCII (useful for log collectors / non-TTY pipelines). Closes
[#168](https://github.com/colbymchenry/codegraph/issues/168). Thanks to
[@starkleek](https://github.com/starkleek) for the report and to
[@Bortlesboat](https://github.com/Bortlesboat) for the initial PR.

[0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10

Expand Down
170 changes: 170 additions & 0 deletions __tests__/glyphs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Glyph fallback / Unicode-support detection.
*
* Pinned because the matrix is small and the consequence of regression
* is highly visible: shimmer-worker output on Windows mojibakes when
* UTF-8 glyphs are written via `fs.writeSync` (see #168). The detection
* + ASCII fallback is the contract that prevents this.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
supportsUnicode,
getGlyphs,
UNICODE_GLYPHS,
ASCII_GLYPHS,
_resetGlyphsCache,
} from '../src/ui/glyphs';

function withEnv(patch: Record<string, string | undefined>, fn: () => void): void {
const saved: Record<string, string | undefined> = {};
const savedPlatform = process.platform;
for (const key of Object.keys(patch)) {
saved[key] = process.env[key];
if (patch[key] === undefined) delete process.env[key];
else process.env[key] = patch[key];
}
_resetGlyphsCache();
try {
fn();
} finally {
for (const key of Object.keys(saved)) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
Object.defineProperty(process, 'platform', { value: savedPlatform });
_resetGlyphsCache();
}
}

function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value });
}

describe('supportsUnicode', () => {
let originalPlatform: NodeJS.Platform;

beforeEach(() => {
originalPlatform = process.platform;
_resetGlyphsCache();
});

afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
_resetGlyphsCache();
});

it('returns false on Windows by default (mojibake-prone consoles)', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
setPlatform('win32');
expect(supportsUnicode()).toBe(false);
});
});

it('returns true on macOS by default', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
setPlatform('darwin');
expect(supportsUnicode()).toBe(true);
});
});

it('returns true on Linux by default', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
setPlatform('linux');
expect(supportsUnicode()).toBe(true);
});
});

it('returns false on Linux kernel console (TERM=linux)', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: 'linux' }, () => {
setPlatform('linux');
expect(supportsUnicode()).toBe(false);
});
});

it('respects CODEGRAPH_UNICODE=1 on Windows (opt-in escape hatch)', () => {
withEnv({ CODEGRAPH_UNICODE: '1', CODEGRAPH_ASCII: undefined }, () => {
setPlatform('win32');
expect(supportsUnicode()).toBe(true);
});
});

it('respects CODEGRAPH_ASCII=1 on macOS (opt-out escape hatch)', () => {
withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('darwin');
expect(supportsUnicode()).toBe(false);
});
});

it('CODEGRAPH_ASCII takes precedence over CODEGRAPH_UNICODE', () => {
withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: '1' }, () => {
setPlatform('darwin');
expect(supportsUnicode()).toBe(false);
});
});
});

describe('getGlyphs', () => {
let originalPlatform: NodeJS.Platform;

beforeEach(() => {
originalPlatform = process.platform;
_resetGlyphsCache();
});

afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
_resetGlyphsCache();
});

it('returns ASCII glyphs on Windows', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('win32');
const g = getGlyphs();
expect(g).toBe(ASCII_GLYPHS);
expect(g.ok).toBe('[OK]');
expect(g.rail).toBe('|');
expect(g.phaseDone).toBe('*');
expect(g.dash).toBe('-');
});
});

it('returns Unicode glyphs on macOS', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('darwin');
const g = getGlyphs();
expect(g).toBe(UNICODE_GLYPHS);
expect(g.ok).toBe('✓');
expect(g.rail).toBe('│');
expect(g.phaseDone).toBe('◆');
expect(g.dash).toBe('—');
});
});

it('caches the result so repeated calls return the same object', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('darwin');
expect(getGlyphs()).toBe(getGlyphs());
});
});
});

describe('Glyph sets', () => {
it('ASCII and Unicode sets cover the same keys', () => {
expect(Object.keys(ASCII_GLYPHS).sort()).toEqual(Object.keys(UNICODE_GLYPHS).sort());
});

it('ASCII glyphs are all 7-bit ASCII', () => {
for (const [key, value] of Object.entries(ASCII_GLYPHS)) {
const flat = Array.isArray(value) ? value.join('') : value;
for (let i = 0; i < flat.length; i++) {
const codepoint = flat.charCodeAt(i);
expect(codepoint, `ASCII_GLYPHS.${key} contains non-ASCII char U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`).toBeLessThan(128);
}
}
});

it('ASCII spinner has the same frame count as the Unicode spinner', () => {
expect(ASCII_GLYPHS.spinner.length).toBe(UNICODE_GLYPHS.spinner.length);
});
});
42 changes: 22 additions & 20 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import * as path from 'path';
import * as fs from 'fs';
import { getCodeGraphDir, isInitialized } from '../directory';
import { createShimmerProgress } from '../ui/shimmer-progress';
import { getGlyphs } from '../ui/glyphs';

import { buildNode25BlockBanner } from './node-version-check';

Expand All @@ -32,7 +33,7 @@ async function loadCodeGraph(): Promise<typeof import('../index')> {
return await import('../index');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('\x1b[31m\x1b[0m Failed to load CodeGraph modules.');
console.error(`\x1b[31m${getGlyphs().err}\x1b[0m Failed to load CodeGraph modules.`);
console.error(`\n Node: ${process.version} Platform: ${process.platform} ${process.arch}`);
console.error(`\n Error: ${msg}`);
console.error('\n Try reinstalling with: npm install -g @colbymchenry/codegraph\n');
Expand Down Expand Up @@ -212,7 +213,7 @@ function createVerboseProgress(): (progress: { phase: string; current: number; t
// Log every 5% to keep output manageable
if (pct >= lastPct + 5 || progress.current === progress.total) {
lastPct = pct;
console.log(`[${elapsed}s] ${progress.current}/${progress.total} (${pct}%)${progress.currentFile ? ` ${progress.currentFile}` : ''}`);
console.log(`[${elapsed}s] ${progress.current}/${progress.total} (${pct}%)${progress.currentFile ? ` ${getGlyphs().dash} ${progress.currentFile}` : ''}`);
}
} else if (progress.current > 0) {
// Scanning phase (no total yet) — log periodically
Expand All @@ -227,28 +228,28 @@ function createVerboseProgress(): (progress: { phase: string; current: number; t
* Print success message
*/
function success(message: string): void {
console.log(chalk.green('✓') + ' ' + message);
console.log(chalk.green(getGlyphs().ok) + ' ' + message);
}

/**
* Print error message
*/
function error(message: string): void {
console.error(chalk.red('✗') + ' ' + message);
console.error(chalk.red(getGlyphs().err) + ' ' + message);
}

/**
* Print info message
*/
function info(message: string): void {
console.log(chalk.blue('ℹ') + ' ' + message);
console.log(chalk.blue(getGlyphs().info) + ' ' + message);
}

/**
* Print warning message
*/
function warn(message: string): void {
console.log(chalk.yellow('⚠') + ' ' + message);
console.log(chalk.yellow(getGlyphs().warn) + ' ' + message);
}

type IndexResult = {
Expand Down Expand Up @@ -281,7 +282,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
// continuing to the misleading "No files found" branch or throwing.
if (!result.success && !hasErrors && result.filesIndexed === 0) {
const generic = result.errors.find((e) => e.severity === 'error');
clack.log.error(generic?.message ?? 'Indexing failed no further details available');
clack.log.error(generic?.message ?? `Indexing failed ${getGlyphs().dash} no further details available`);
return;
}

Expand All @@ -293,7 +294,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
}
clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`);
} else if (hasErrors) {
clack.log.error(`Indexing failed all ${formatNumber(result.filesErrored)} files had errors`);
clack.log.error(`Indexing failed ${getGlyphs().dash} all ${formatNumber(result.filesErrored)} files had errors`);
} else {
clack.log.warn('No files found to index');
}
Expand Down Expand Up @@ -327,7 +328,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
}

if (result.filesIndexed > 0) {
clack.log.info('The index is fully usable only the failed files are missing.');
clack.log.info(`The index is fully usable ${getGlyphs().dash} only the failed files are missing.`);
}
} else if (projectPath) {
const logPath = path.join(projectPath, '.codegraph', 'errors.log');
Expand Down Expand Up @@ -365,7 +366,7 @@ function writeErrorLog(projectPath: string, errors: Array<{ message: string; fil
}

const lines: string[] = [
`CodeGraph Error Log ${new Date().toISOString()}`,
`CodeGraph Error Log - ${new Date().toISOString()}`,
`${errorsByFile.size} files with errors`,
'',
];
Expand Down Expand Up @@ -445,7 +446,7 @@ program
verbose: true,
});
} else {
process.stdout.write(`${colors.dim}${colors.reset}\n`);
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
const progress = createShimmerProgress();
result = await cg.indexAll({
onProgress: progress.onProgress,
Expand Down Expand Up @@ -488,7 +489,7 @@ program
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) => {
rl.question(
chalk.yellow('⚠ This will permanently delete all CodeGraph data. Continue? (y/N) '),
chalk.yellow(`${getGlyphs().warn} This will permanently delete all CodeGraph data. Continue? (y/N) `),
resolve
);
});
Expand Down Expand Up @@ -558,7 +559,7 @@ program
verbose: true,
});
} else {
process.stdout.write(`${colors.dim}${colors.reset}\n`);
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
const progress = createShimmerProgress();
result = await cg.indexAll({
onProgress: progress.onProgress,
Expand Down Expand Up @@ -610,7 +611,7 @@ program
const clack = await importESM('@clack/prompts');
clack.intro('Syncing CodeGraph');

process.stdout.write(`${colors.dim}${colors.reset}\n`);
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
const progress = createShimmerProgress();

const result = await cg.sync({
Expand All @@ -629,7 +630,7 @@ program
if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`);
if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`);
if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`);
clack.log.info(`${details.join(', ')} ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`);
clack.log.info(`${details.join(', ')} ${getGlyphs().dash} ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`);
}

clack.outro('Done');
Expand Down Expand Up @@ -711,7 +712,7 @@ program
// when the native build fails.
const backendLabel = backend === 'native'
? chalk.green('native')
: chalk.yellow('wasm slower fallback; run `npm rebuild better-sqlite3`');
: chalk.yellow(`wasm ${getGlyphs().dash} slower fallback; run \`npm rebuild better-sqlite3\``);
console.log(` Backend: ${backendLabel}`);
console.log();

Expand Down Expand Up @@ -1000,8 +1001,9 @@ function printFileTree(
const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
if (maxDepth !== undefined && depth > maxDepth) return;

const connector = isLast ? '└── ' : '├── ';
const childPrefix = isLast ? ' ' : '│ ';
const glyphs = getGlyphs();
const connector = isLast ? glyphs.treeLast : glyphs.treeBranch;
const childPrefix = isLast ? ' ' : glyphs.treePipe;

if (node.name) {
let line = prefix + connector + node.name;
Expand Down Expand Up @@ -1097,7 +1099,7 @@ program
// Default: show info about MCP mode.
// Use stderr so stdout stays clean for any piped/stdio usage.
console.error(chalk.bold('\nCodeGraph MCP Server\n'));
console.error(chalk.blue('ℹ') + ' Use --mcp flag to start the MCP server');
console.error(chalk.blue(getGlyphs().info) + ' Use --mcp flag to start the MCP server');
console.error('\nTo use with Claude Code, add to your MCP configuration:');
console.error(chalk.dim(`
{
Expand Down Expand Up @@ -1143,7 +1145,7 @@ program
const lockPath = path.join(getCodeGraphDir(projectPath), 'codegraph.lock');

if (!fs.existsSync(lockPath)) {
info('No lock file found nothing to do');
info(`No lock file found ${getGlyphs().dash} nothing to do`);
return;
}

Expand Down
7 changes: 5 additions & 2 deletions src/bin/node-version-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@
* unsupported Node.js major version (currently 25+). Pinned via unit
* test so the recovery commands and override instructions can't be
* silently stripped by future edits.
*
* Uses ASCII glyphs to stay readable on Windows OEM-codepage consoles
* (see ../ui/glyphs.ts for the rationale).
*/
export function buildNode25BlockBanner(nodeVersion: string): string {
const sep = ''.repeat(72);
const sep = '-'.repeat(72);
return [
sep,
`[CodeGraph] Unsupported Node.js version: ${nodeVersion}`,
Expand All @@ -29,7 +32,7 @@ export function buildNode25BlockBanner(nodeVersion: string): string {
' nvm install 22 && nvm use 22 # nvm',
' brew install node@22 && brew link --overwrite --force node@22 # Homebrew',
'',
'To override (NOT recommended you will likely OOM):',
'To override (NOT recommended - you will likely OOM):',
' CODEGRAPH_ALLOW_UNSAFE_NODE=1 codegraph ...',
sep,
].join('\n');
Expand Down
Loading