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

## [Unreleased]

### New Features

- **Find test files without guessing their names.** `codegraph files --json` now marks each file as a test or source file, `--tests` and `--no-tests` filter the list, and `codegraph node --file` labels test files in its header. (#1877)

### Fixes

- In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586)
Expand Down
51 changes: 51 additions & 0 deletions __tests__/cli-files-test-classification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterAll, beforeAll, describe, expect, it } 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');

describe('CLI test-file classification (#1877)', () => {
let dir: string;

function run(command: string, args: string[]): string {
return execFileSync(process.execPath, [BIN, command, ...args, '-p', dir], {
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
}

beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-files-'));
fs.mkdirSync(path.join(dir, 'src'));
fs.writeFileSync(path.join(dir, 'src/app.ts'), 'export function app() { return 1; }\n');
fs.writeFileSync(path.join(dir, 'src/app.test.ts'), 'import { app } from "./app";\napp();\n');
const cg = CodeGraph.initSync(dir);
await cg.indexAll();
cg.close();
});

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

it('reports the shared test-file predicate in files JSON and filters by it', () => {
const all = JSON.parse(run('files', ['--json']));
expect(all).toEqual(expect.arrayContaining([
expect.objectContaining({ path: 'src/app.ts', isTest: false }),
expect.objectContaining({ path: 'src/app.test.ts', isTest: true }),
]));
expect(JSON.parse(run('files', ['--json', '--tests'])).map((file: { path: string }) => file.path))
.toEqual(['src/app.test.ts']);
expect(JSON.parse(run('files', ['--json', '--no-tests'])).map((file: { path: string }) => file.path))
.toEqual(['src/app.ts']);
});

it('marks test files in the node file header', () => {
expect(run('node', ['-f', 'src/app.test.ts', '--symbols-only']))
.toContain('**src/app.test.ts** · test file');
expect(run('node', ['-f', 'src/app.ts', '--symbols-only']))
.not.toContain('test file');
});
});
8 changes: 8 additions & 0 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1740,6 +1740,8 @@ program
.option('-p, --path <path>', 'Project path')
.option('--filter <dir>', 'Filter to files under this directory')
.option('--pattern <glob>', 'Filter files matching this glob pattern')
.option('--tests', 'Show only test files')
.option('--no-tests', 'Exclude test files')
.option('--format <format>', 'Output format (tree, flat, grouped)', 'tree')
.option('--max-depth <number>', 'Maximum directory depth for tree format')
.option('--no-metadata', 'Hide file metadata (language, symbol count)')
Expand All @@ -1748,6 +1750,7 @@ program
path?: string;
filter?: string;
pattern?: string;
tests?: boolean;
format?: string;
maxDepth?: string;
metadata?: boolean;
Expand Down Expand Up @@ -1783,6 +1786,10 @@ program
files = files.filter(f => regex.test(f.path));
}

if (options.tests !== undefined) {
files = files.filter(f => isTestPath(f.path) === options.tests);
}

if (files.length === 0) {
info('No files found matching the criteria.');
cg.destroy();
Expand All @@ -1793,6 +1800,7 @@ program
if (options.json) {
const output = files.map(f => ({
path: f.path,
isTest: isTestPath(f.path),
language: f.language,
nodeCount: f.nodeCount,
size: f.size,
Expand Down
13 changes: 7 additions & 6 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
} from '../sync/worktree';
import type { PendingFile } from '../sync';
import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types';
import { isTestFile, normalizeNameToken } from '../search/query-utils';
import { isTestFile, isTestPath, normalizeNameToken } from '../search/query-utils';
import { groupDefinitions, isQualifiedSymbol, lastQualifierPart, matchesSymbol, qualifiedSymbolMatches } from '../graph/symbol-lookup';
import { goPackageReaderFor } from '../graph/go-package';
import {
Expand Down Expand Up @@ -8046,6 +8046,7 @@ export class ToolHandler {
}

const filePath = resolved.path;
const testLabel = isTestPath(filePath) ? ' · test file' : '';
const nodes = cg.getNodesInFile(filePath)
.filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
.sort((a, b) => a.startLine - b.startLine);
Expand All @@ -8069,7 +8070,7 @@ export class ToolHandler {

// symbolsOnly → the cheap structural overview, no source.
if (opts.symbolsOnly) {
const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
const out = [`**${filePath}**${testLabel} — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
if (nodes.length) out.push(...symbolMap('**Symbols**'));
else out.push('_No indexed symbols in this file._');
out.push('', '> Drop `symbolsOnly` (or pass `offset`/`limit`) to read the source, like Read.');
Expand All @@ -8079,7 +8080,7 @@ export class ToolHandler {
// SECURITY (#383): never dump a raw config/data file — a yaml/properties
// line is `key: <secret>`. Summarize by key and point to a real Read.
if (CONFIG_LEAF_LANGUAGES.has(resolved.language)) {
const out = [`**${filePath}** — configuration/data file, ${depSummary}`, ''];
const out = [`**${filePath}**${testLabel} — configuration/data file, ${depSummary}`, ''];
if (nodes.length) out.push(...symbolMap('**Keys (values withheld for safety)**'));
out.push('', '> Values may be secrets, so codegraph indexes keys only. Read the file directly if you need a value.');
return this.textResult(this.truncateOutput(out.join('\n')));
Expand All @@ -8093,7 +8094,7 @@ export class ToolHandler {
try { content = readFileSync(abs, 'utf-8'); } catch { content = null; }
}
if (content === null) {
const out = [`**${filePath}** — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
const out = [`**${filePath}**${testLabel} — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
if (nodes.length) out.push(...symbolMap('**Symbols**'));
out.push('', `> Read \`${filePath}\` directly for its current content.`);
return this.textResult(this.truncateOutput(out.join('\n')));
Expand All @@ -8113,11 +8114,11 @@ export class ToolHandler {
const DEFAULT_LIMIT = 2000;
const offset = Math.max(1, opts.offset ?? 1);
if (offset > total) {
return this.textResult(`**${filePath}** has ${total} line${total === 1 ? '' : 's'} — offset ${offset} is past the end. ${depSummary}`);
return this.textResult(`**${filePath}**${testLabel} has ${total} line${total === 1 ? '' : 's'} — offset ${offset} is past the end. ${depSummary}`);
}
const maxLines = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
const start = offset - 1; // 0-based
const header = `**${filePath}** — ${total} lines, ${nodes.length} symbol${nodes.length === 1 ? '' : 's'} · ${depSummary}`;
const header = `**${filePath}**${testLabel} — ${total} lines, ${nodes.length} symbol${nodes.length === 1 ? '' : 's'} · ${depSummary}`;

// Numbered lines, byte-for-byte Read's shape: `<n>\t<line>`, no left-pad.
const numbered: string[] = [];
Expand Down