From 38fffed1f63a496400f32237de2cf70fe63eef1b Mon Sep 17 00:00:00 2001 From: Max Hsu Date: Sat, 26 Sep 2026 09:40:26 +0900 Subject: [PATCH] feat(cli): expose test-file classification --- CHANGELOG.md | 4 ++ .../cli-files-test-classification.test.ts | 51 +++++++++++++++++++ src/bin/codegraph.ts | 8 +++ src/mcp/tools.ts | 13 ++--- 4 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 __tests__/cli-files-test-classification.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f9099acd..1f78560e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) diff --git a/__tests__/cli-files-test-classification.test.ts b/__tests__/cli-files-test-classification.test.ts new file mode 100644 index 0000000000..3c837827bd --- /dev/null +++ b/__tests__/cli-files-test-classification.test.ts @@ -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'); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index aeae56d669..6457c102c5 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1740,6 +1740,8 @@ program .option('-p, --path ', 'Project path') .option('--filter ', 'Filter to files under this directory') .option('--pattern ', 'Filter files matching this glob pattern') + .option('--tests', 'Show only test files') + .option('--no-tests', 'Exclude test files') .option('--format ', 'Output format (tree, flat, grouped)', 'tree') .option('--max-depth ', 'Maximum directory depth for tree format') .option('--no-metadata', 'Hide file metadata (language, symbol count)') @@ -1748,6 +1750,7 @@ program path?: string; filter?: string; pattern?: string; + tests?: boolean; format?: string; maxDepth?: string; metadata?: boolean; @@ -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(); @@ -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, diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index d3fc528ac8..d2448770cd 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -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 { @@ -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); @@ -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.'); @@ -8079,7 +8080,7 @@ export class ToolHandler { // SECURITY (#383): never dump a raw config/data file — a yaml/properties // line is `key: `. 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'))); @@ -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'))); @@ -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: `\t`, no left-pad. const numbered: string[] = [];