diff --git a/src/tools/file-tree.ts b/src/tools/file-tree.ts index 3b4a23d..5194a79 100644 --- a/src/tools/file-tree.ts +++ b/src/tools/file-tree.ts @@ -2,7 +2,7 @@ // Copyright (c) 2025-2026 Four Bytes import { tool } from '@opencode-ai/plugin'; -import { readdirSync, statSync, existsSync } from 'node:fs'; +import { readdirSync, lstatSync, statSync, existsSync } from 'node:fs'; import { join, basename } from 'node:path'; import { logDebugEvent } from '../lib/debug-logger'; @@ -34,18 +34,20 @@ function walkDir( } for (const entry of entries.sort()) { - if (!includeHidden && entry.startsWith('.') && entry !== '.gitignore') continue; + if (!includeHidden && entry.startsWith('.')) continue; const fullPath = join(dir, entry); let stat; try { - stat = statSync(fullPath); + stat = lstatSync(fullPath); } catch { continue; } + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { - if (SKIP_DIRS.has(entry) && !includeHidden) continue; + if (SKIP_DIRS.has(entry)) continue; const children = walkDir(fullPath, depth + 1, maxDepth, filter, includeHidden); results.push({ name: entry + '/', @@ -100,7 +102,7 @@ export const fileTreeTool = tool({ return { title: displayName, output: JSON.stringify(singleFile, null, 2), - metadata: { entries: [singleFile] }, + metadata: { tree: [singleFile] }, }; } diff --git a/tests/file-tree.test.ts b/tests/file-tree.test.ts index f168934..c38be1d 100644 --- a/tests/file-tree.test.ts +++ b/tests/file-tree.test.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2025-2026 Four Bytes +/* global AbortController */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { mkdirSync, writeFileSync, rmSync, existsSync, symlinkSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { fileTreeTool } from '../src/tools/file-tree'; @@ -49,14 +50,73 @@ describe('file_tree tool', () => { expect(() => JSON.parse(result.output)).not.toThrow(); }); - it('returns file info for single file path', async () => { + it('returns file info for single file path (metadata.tree)', async () => { const result = await fileTreeTool.execute({ path: join(testDir, 'file1.txt') }, mockCtx()); - expect(Array.isArray(result.metadata.entries)).toBe(true); - expect(result.metadata.entries[0].type).toBe('file'); - expect(result.metadata.entries[0].size).toBe(5); + expect(Array.isArray(result.metadata.tree)).toBe(true); + expect(result.metadata.tree[0].type).toBe('file'); + expect(result.metadata.tree[0].size).toBe(5); }); it('throws on nonexistent path', async () => { - await expect(fileTreeTool.execute({ path: '/tmp/nonexistent-file-tree' }, mockCtx())).rejects.toThrow('Path not found'); + await expect( + fileTreeTool.execute({ path: '/tmp/nonexistent-file-tree' }, mockCtx()) + ).rejects.toThrow('Path not found'); + }); + + it('filters files by glob pattern', async () => { + const result = await fileTreeTool.execute( + { path: testDir, depth: 2, filter: '*.txt' }, + mockCtx() + ); + const names = result.metadata.tree.map((n: { name: string }) => n.name); + expect(names).toContain('file1.txt'); + // filter only applies to files, not directories + expect(names).toContain('subdir/'); + // but non-txt files inside subdir are filtered out + const subdir = result.metadata.tree.find((n: { name: string }) => n.name === 'subdir/'); + if (subdir?.children) { + expect(subdir.children.some((c: { name: string }) => c.name === 'file2.ts')).toBe(false); + } + }); + + it('respects depth limit', async () => { + mkdirSync(join(testDir, 'subdir', 'deep'), { recursive: true }); + writeFileSync(join(testDir, 'subdir', 'deep', 'nested.txt'), 'deep', 'utf-8'); + const result = await fileTreeTool.execute({ path: testDir, depth: 0 }, mockCtx()); + const subdir = result.metadata.tree.find((n: { name: string }) => n.name === 'subdir/'); + expect(subdir?.children).toBeUndefined(); + }); + + it('includes hidden files when include_hidden is true', async () => { + writeFileSync(join(testDir, '.hidden'), 'secret', 'utf-8'); + const result = await fileTreeTool.execute( + { path: testDir, depth: 2, include_hidden: true }, + mockCtx() + ); + const names = result.metadata.tree.map((n: { name: string }) => n.name); + expect(names).toContain('.hidden'); + }); + + it('excludes hidden files by default', async () => { + writeFileSync(join(testDir, '.hidden'), 'secret', 'utf-8'); + const result = await fileTreeTool.execute({ path: testDir, depth: 2 }, mockCtx()); + const names = result.metadata.tree.map((n: { name: string }) => n.name); + expect(names).not.toContain('.hidden'); + }); + + it('skips symlinked directories to prevent cycles', async () => { + mkdirSync(join(testDir, 'target'), { recursive: true }); + writeFileSync(join(testDir, 'target', 'real.txt'), 'real', 'utf-8'); + symlinkSync(join(testDir, 'target'), join(testDir, 'link')); + const result = await fileTreeTool.execute({ path: testDir, depth: 3 }, mockCtx()); + const _names = result.metadata.tree.map((n: { name: string }) => n.name); + // symlink dir itself may appear but its children must not be recursed + const link = result.metadata.tree.find((n: { name: string }) => n.name === 'link/'); + if (link) { + expect(link.children).toBeUndefined(); + } + // target's children should still appear + const target = result.metadata.tree.find((n: { name: string }) => n.name === 'target/'); + expect(target?.children?.some((c: { name: string }) => c.name === 'real.txt')).toBe(true); }); });