diff --git a/CHANGELOG.md b/CHANGELOG.md index 643d4d900..c864a0aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features - `codegraph files --format package` and `codegraph_files` with `format: "package"` now list a project's files by the package that owns them, the nearest `package.json` or `composer.json` above each file that names one, so a monorepo splits into its workspace members with no setup and files nothing claims get a section of their own (#1871). Thanks @LuisAlbertoMK. +- `codegraph files --json` now marks each file as a test file or not, `--tests` and `--no-tests` filter the list in any format, and `codegraph node --file` says "test file" in its header, so your own tooling can ask CodeGraph instead of keeping a copy of its naming rules. Thanks @maxmilian. (#1877) - `codegraph install` now sets up IBM Bob, for Bob IDE and Bob Shell: it adds the CodeGraph MCP server to `~/.bob/settings/mcp.json`, or to `.bob/mcp.json` in a project. Thanks @QuentinDstl. - `codegraph install` now sets up Factory Droid: it adds the CodeGraph MCP server to `~/.factory/mcp.json` or a project's `.factory/mcp.json`, plus a short CodeGraph section in `AGENTS.md`, and Droid picks it up without a restart. Thanks @Rubyj and @KrystianMarek. - `codegraph install` now sets up Windsurf: it adds the CodeGraph MCP server to `~/.codeium/windsurf/mcp_config.json`, which Devin, Windsurf's new name, imports by default. Thanks @SEPURI-SAI-KRISHNA. diff --git a/README.md b/README.md index 27c5f5aa9..9511ef7ac 100644 --- a/README.md +++ b/README.md @@ -548,7 +548,7 @@ codegraph unlock [path] # Remove a stale lock file that's blocking ind codegraph query # Search symbols (--kind, --limit, --json) codegraph explore # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool) codegraph node # One symbol's source + callers, or read a file with line numbers (same output as codegraph_node) -codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json) +codegraph files [path] # Show file structure (--format, --filter, --max-depth, --tests, --json) codegraph callers # Find what calls a function/method (--limit, --json) codegraph callees # Find what a function/method calls (--limit, --json) codegraph impact # Analyze what code is affected by changing a symbol (--depth, --json) diff --git a/__tests__/cli-files-test-classification.test.ts b/__tests__/cli-files-test-classification.test.ts new file mode 100644 index 000000000..928460b1a --- /dev/null +++ b/__tests__/cli-files-test-classification.test.ts @@ -0,0 +1,66 @@ +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('keeps the filter in every output format', () => { + for (const format of ['tree', 'flat', 'grouped', 'package']) { + const tests = run('files', ['--tests', '--format', format]); + expect(tests).toContain('app.test.ts'); + expect(tests).not.toMatch(/app\.ts/); + const source = run('files', ['--no-tests', '--format', format]); + expect(source).toMatch(/app\.ts/); + expect(source).not.toContain('app.test.ts'); + } + }); + + it('says so when a filter leaves no files', () => { + expect(run('files', ['--tests', '--filter', 'src/app.ts'])).toContain('No files found'); + }); + + 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/__tests__/owning-package.test.ts b/__tests__/owning-package.test.ts index 5a606a46b..f6df0ec67 100644 --- a/__tests__/owning-package.test.ts +++ b/__tests__/owning-package.test.ts @@ -329,7 +329,7 @@ describe('listing an indexed project by package', () => { const plain = JSON.parse(codegraph('files', '--json')) as Array>; expect(plain.map((file) => Object.keys(file).sort())).toEqual( - plain.map(() => ['errors', 'language', 'nodeCount', 'path', 'size']), + plain.map(() => ['errors', 'isTest', 'language', 'nodeCount', 'path', 'size']), ); expect(JSON.parse(codegraph('files', '--json', '--format', 'package'))).toEqual(plain); }); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 001d35036..1cc271e11 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1897,6 +1897,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, package)', 'tree') .option('--max-depth ', 'Maximum directory depth for tree format') .option('--no-metadata', 'Hide file metadata (language, symbol count)') @@ -1905,6 +1907,7 @@ program path?: string; filter?: string; pattern?: string; + tests?: boolean; format?: string; maxDepth?: string; metadata?: boolean; @@ -1939,6 +1942,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(); @@ -1949,6 +1956,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 9de05825f..b8e7dcbf1 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, Language } from '../types'; -import { isTestFile, normalizeNameToken, splitAtCjkSwitches, isQueryNameToken } from '../search/query-utils'; +import { isTestFile, isTestPath, normalizeNameToken, splitAtCjkSwitches, isQueryNameToken } from '../search/query-utils'; import { groupDefinitions, isQualifiedSymbol, lastQualifierPart, matchesSymbol, qualifiedSymbolMatches } from '../graph/symbol-lookup'; import { goPackageReaderFor } from '../graph/go-package'; import { @@ -8277,6 +8277,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); @@ -8300,7 +8301,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.'); @@ -8310,7 +8311,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'))); @@ -8324,7 +8325,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'))); @@ -8344,11 +8345,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[] = [];