Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Command output is now clean when piped or captured: colors only render when stdout is a terminal, so redirecting `codegraph query`, `status`, `callers`, and friends to a file or another tool no longer embeds ANSI escape codes. The standard `NO_COLOR` environment variable (no-color.org) is honored, and a `--no-color` flag is available to force plain output in a terminal too. (#1281)
- Callers and impact analysis no longer silently under-count a function that calls the same callee many times. When one caller contained several call sites to the same callee and an internal resolution batch boundary happened to split them, cleanup after the first batch removed the later sites' pending rows before they were ever attempted — their edges were never created, deterministically, and which edges went missing shifted with unrelated changes to the project's total reference count. Post-pass cleanup now targets the exact database row each processed reference came from. Found while validating the operator-call fix on nlohmann/json, where `write_cbor`'s 11 calls to `to_char_type` indexed as 10. (#1269)
- C++ explicit operator calls — `a.operator+(b)`, `p->operator+(b)`, `a.operator[](3)`, and the other symbolic forms — now produce a `calls` edge to the operator method, so an operator invoked only through the explicit syntax no longer looks uncalled in callers and impact analysis. tree-sitter parses these call sites with the operator name stranded in an error node (never as a normal member access), so the call's target was silently read as just the receiver variable; the operator name is now recovered from the error node and resolved through receiver-type inference like any other member call — a same-named operator on an unrelated class can never capture the edge. Infix uses (`a + b`, `a[i]`) need real type inference and are tracked separately. (#1247)
- `codegraph init` and `codegraph index` no longer look hung after "Resolving refs" reaches 100%. The dynamic-dispatch linking that runs after resolution (callbacks, React re-renders, C function pointers, and the rest) had no progress display, so on repos where it takes a while — large C codebases especially — the bar just sat frozen at 100% until it finished. That work now shows as its own "Linking dynamic dispatch" progress phase, and the heaviest pass — C function-pointer linking — additionally reports progress within the pass, so a large C codebase advances the bar smoothly instead of parking it on one number for the bulk of the phase.
Expand Down
88 changes: 88 additions & 0 deletions __tests__/cli-no-color.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* ANSI color suppression for piped / agent consumption (#1281).
*
* Human-format output (`query`, `status`, …) used to embed ANSI color codes
* unconditionally: piping to a file or another tool captured the escapes, and
* the standard `NO_COLOR` escape hatch (https://no-color.org) was ignored.
*
* The CLI now emits colors only when stdout is a TTY, and honors both a
* non-empty `NO_COLOR` environment variable and a `--no-color` flag. These
* tests run the built binary with stdout piped (never a TTY), so every mode
* below must produce escape-free output.
*
* Exercised end-to-end against the built binary.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';

const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
const ANSI_ESCAPE = /\x1b\[/;

function run(cwd: string, args: string[], extraEnv: Record<string, string> = {}): string {
return execFileSync(process.execPath, [BIN, ...args], {
cwd, // `status` resolves the project from cwd (it has no `-p` option)
encoding: 'utf-8',
env: {
...process.env,
CODEGRAPH_NO_DAEMON: '1',
CODEGRAPH_WASM_RELAUNCHED: '1',
NO_COLOR: '', // an empty NO_COLOR must NOT disable color per no-color.org — the pipe gate below is what keeps these runs clean
...extraEnv,
},
stdio: ['ignore', 'pipe', 'ignore'], // drop stderr (SQLite experimental warning)
});
}

describe('CLI ANSI color suppression (#1281)', () => {
let tempDir: string;

beforeAll(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-no-color-'));
fs.mkdirSync(path.join(tempDir, 'src'));
fs.writeFileSync(
path.join(tempDir, 'src/auth.ts'),
'export function parseToken(t: string){ return t.trim(); }\n' +
'export function checkToken(t: string){ return parseToken(t).length > 0; }\n',
);
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.close();
});

afterAll(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('query human output is escape-free when stdout is piped (non-TTY default)', () => {
const out = run(tempDir, ['query', 'parseToken', '-l', '5']);
expect(out).toContain('parseToken');
expect(out).not.toMatch(ANSI_ESCAPE);
});

it('status human output is escape-free when stdout is piped', () => {
const out = run(tempDir, ['status']);
expect(out).not.toMatch(ANSI_ESCAPE);
});

it('callers human output is escape-free when stdout is piped', () => {
const out = run(tempDir, ['callers', 'parseToken']);
expect(out).not.toMatch(ANSI_ESCAPE);
});

it('a non-empty NO_COLOR is honored', () => {
const out = run(tempDir, ['query', 'parseToken', '-l', '5'], { NO_COLOR: '1' });
expect(out).toContain('parseToken');
expect(out).not.toMatch(ANSI_ESCAPE);
});

it('--no-color is accepted after a subcommand and produces clean output', () => {
const out = run(tempDir, ['query', 'parseToken', '-l', '5', '--no-color']);
expect(out).toContain('parseToken');
expect(out).not.toMatch(ANSI_ESCAPE);
});
});
52 changes: 39 additions & 13 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { extractProseCandidates } from '../search/identifier-segments';
import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
import { createShimmerProgress } from '../ui/shimmer-progress';
import { getGlyphs } from '../ui/glyphs';
import { colorEnabled } from '../ui/colors';

import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
import { installFatalHandlers } from './fatal-handler';
Expand Down Expand Up @@ -143,18 +144,40 @@ if (firstArg === '-v' || firstArg === '-version') {
// ANSI Color Helpers (avoid chalk ESM issues)
// =============================================================================

const colors = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
white: '\x1b[37m',
gray: '\x1b[90m',
};
// Decide color once, then strip `--no-color` before commander parses: it's a
// global affordance that must also work after a subcommand
// (`codegraph query … --no-color`), where commander would reject it as an
// unknown option. Same pre-parse intercept pattern as `-v` above. (#1281)
const useColor = colorEnabled();
process.argv = process.argv.filter((arg) => arg !== '--no-color');

// With color disabled every code is an empty string, so both the `colors.X`
// interpolations and the `chalk.X()` helpers below become plain-text no-ops.
const colors = useColor
? {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
white: '\x1b[37m',
gray: '\x1b[90m',
}
: {
reset: '',
bold: '',
dim: '',
red: '',
green: '',
yellow: '',
blue: '',
cyan: '',
white: '',
gray: '',
};

const chalk = {
bold: (s: string) => `${colors.bold}${s}${colors.reset}`,
Expand All @@ -171,7 +194,10 @@ const chalk = {
program
.name('codegraph')
.description('Code intelligence and knowledge graph for any codebase')
.version(packageJson.version);
.version(packageJson.version)
// Parsed by the pre-parse intercept above (and stripped from argv, so it
// works after subcommands too); registered here so it shows in --help.
.option('--no-color', 'disable ANSI colors (also honors NO_COLOR; color is off by default when stdout is not a TTY)');

// Anonymous usage telemetry (see TELEMETRY.md): record the invoked subcommand
// NAME only — never arguments or paths. Counts buffer locally; network sends
Expand Down
22 changes: 22 additions & 0 deletions src/ui/colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Whether human-format CLI output should include ANSI color codes (#1281).
*
* Follows https://no-color.org and common CLI convention:
* - `--no-color` anywhere on the command line disables color
* - a non-empty `NO_COLOR` environment variable disables color
* - otherwise color is only enabled when stdout is a TTY, so piped /
* redirected / agent-captured output is always escape-free
*
* `--json` output was already escape-free; this gate covers the human format,
* which is the most token-efficient shape for agent consumers.
*/
export function colorEnabled(
argv: readonly string[] = process.argv,
env: NodeJS.ProcessEnv = process.env,
stdoutIsTTY: boolean = process.stdout.isTTY === true,
): boolean {
if (argv.includes('--no-color')) return false;
// Per no-color.org, NO_COLOR disables color when present and non-empty.
if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false;
return stdoutIsTTY;
}
16 changes: 11 additions & 5 deletions src/upgrade/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import { spawnSync } from 'child_process';
import { colorEnabled } from '../ui/colors';

export const REPO = 'colbymchenry/codegraph';
export const NPM_PACKAGE = '@colbymchenry/codegraph';
Expand Down Expand Up @@ -305,12 +306,17 @@ export interface UpgradeDeps {
offerBetaSignup?: () => Promise<void>;
}

// Evaluated per call so the NO_COLOR / non-TTY gate (#1281) applies at the
// moment of printing, and unit tests (piped stdout) stay escape-free.
const paint = (code: string) => (s: string) =>
colorEnabled() ? `\x1b[${code}m${s}\x1b[0m` : s;

const c = {
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
bold: paint('1'),
dim: paint('2'),
green: paint('32'),
yellow: paint('33'),
cyan: paint('36'),
};

/** The honest, additive re-index reminder shown after a successful upgrade. */
Expand Down