feat: add tree-aware VFS methods to prevent recursion in file explorers

Add safe tree operations that guarantee no directory appears as its own child:
- getDirectChildren() returns only immediate children
- getTreeStructure() builds safe tree with recursion protection
- getDescendants() gets all descendants efficiently
- inspect() provides comprehensive path information

Also includes VFSTreeUtils for building and validating tree structures.

This resolves the common infinite recursion issue when building file
explorers, as discovered by the Soulcraft Studio team.

Co-Authored-By: User <noreply@user.local>
This commit is contained in:
David Snelling 2025-09-26 10:17:59 -07:00
parent 0b20fd24da
commit 72590d52b0
7 changed files with 1261 additions and 4 deletions

358
src/vfs/TreeUtils.ts Normal file
View file

@ -0,0 +1,358 @@
/**
* VFS Tree Utilities
* Provides safe tree operations that prevent common recursion issues
*/
import { VFSEntity, VFSDirent } from './types.js'
export interface TreeNode {
name: string
path: string
type: 'file' | 'directory'
entityId?: string
children?: TreeNode[]
metadata?: any
}
export interface TreeOptions {
maxDepth?: number
includeHidden?: boolean
filter?: (node: VFSEntity) => boolean
sort?: 'name' | 'modified' | 'size'
expandAll?: boolean
}
/**
* Tree utility functions for VFS
* These functions ensure proper tree structure without recursion issues
*/
export class VFSTreeUtils {
/**
* Build a safe tree structure from VFS entities
* Guarantees no directory appears as its own child
*/
static buildTree(
entities: VFSEntity[],
rootPath: string = '/',
options: TreeOptions = {}
): TreeNode {
const pathToEntity = new Map<string, VFSEntity>()
const pathToNode = new Map<string, TreeNode>()
// First pass: index all entities by path
for (const entity of entities) {
const path = entity.metadata.path
// Critical: Skip if entity IS the root we're building from
if (path === rootPath) {
continue
}
pathToEntity.set(path, entity)
}
// Create root node
const rootNode: TreeNode = {
name: rootPath === '/' ? 'root' : rootPath.split('/').pop()!,
path: rootPath,
type: 'directory',
children: []
}
pathToNode.set(rootPath, rootNode)
// Second pass: build tree structure
const sortedPaths = Array.from(pathToEntity.keys()).sort()
for (const path of sortedPaths) {
const entity = pathToEntity.get(path)!
// Apply filter if provided
if (options.filter && !options.filter(entity)) {
continue
}
// Skip hidden files if requested
if (!options.includeHidden && entity.metadata.name.startsWith('.')) {
continue
}
// Create node for this entity
const node: TreeNode = {
name: entity.metadata.name,
path: entity.metadata.path,
type: entity.metadata.vfsType,
entityId: entity.id,
metadata: entity.metadata
}
if (entity.metadata.vfsType === 'directory') {
node.children = []
}
pathToNode.set(path, node)
// Find parent and attach
const parentPath = this.getParentPath(path)
const parentNode = pathToNode.get(parentPath)
if (parentNode && parentNode.children) {
parentNode.children.push(node)
}
}
// Sort children if requested
if (options.sort) {
this.sortTreeNodes(rootNode, options.sort)
}
// Apply depth limit if specified
if (options.maxDepth !== undefined) {
this.limitDepth(rootNode, options.maxDepth)
}
return rootNode
}
/**
* Get direct children only - guaranteed no self-inclusion
*/
static getDirectChildren(
entities: VFSEntity[],
parentPath: string
): VFSEntity[] {
const children: VFSEntity[] = []
const parentDepth = parentPath === '/' ? 0 : parentPath.split('/').length - 1
for (const entity of entities) {
const path = entity.metadata.path
// Critical check 1: Skip if this IS the parent
if (path === parentPath) {
continue
}
// Check if entity is a direct child
if (path.startsWith(parentPath)) {
const relativePath = parentPath === '/'
? path.substring(1)
: path.substring(parentPath.length + 1)
// Direct child has no additional slashes
if (!relativePath.includes('/')) {
children.push(entity)
}
}
}
return children
}
/**
* Get all descendants (recursive children)
*/
static getDescendants(
entities: VFSEntity[],
ancestorPath: string,
includeAncestor: boolean = false
): VFSEntity[] {
const descendants: VFSEntity[] = []
for (const entity of entities) {
const path = entity.metadata.path
// Include ancestor only if explicitly requested
if (path === ancestorPath) {
if (includeAncestor) {
descendants.push(entity)
}
continue
}
// Check if entity is under ancestor path
const prefix = ancestorPath === '/' ? '/' : ancestorPath + '/'
if (path.startsWith(prefix)) {
descendants.push(entity)
}
}
return descendants
}
/**
* Flatten a tree structure back to a list
*/
static flattenTree(node: TreeNode): TreeNode[] {
const result: TreeNode[] = [node]
if (node.children) {
for (const child of node.children) {
result.push(...this.flattenTree(child))
}
}
return result
}
/**
* Find a node in the tree by path
*/
static findNode(root: TreeNode, targetPath: string): TreeNode | null {
if (root.path === targetPath) {
return root
}
if (root.children) {
for (const child of root.children) {
const found = this.findNode(child, targetPath)
if (found) return found
}
}
return null
}
/**
* Calculate tree statistics
*/
static getTreeStats(node: TreeNode): {
totalNodes: number
files: number
directories: number
maxDepth: number
totalSize?: number
} {
let stats = {
totalNodes: 0,
files: 0,
directories: 0,
maxDepth: 0,
totalSize: 0
}
function traverse(n: TreeNode, depth: number) {
stats.totalNodes++
stats.maxDepth = Math.max(stats.maxDepth, depth)
if (n.type === 'file') {
stats.files++
if (n.metadata?.size) {
stats.totalSize += n.metadata.size
}
} else {
stats.directories++
}
if (n.children) {
for (const child of n.children) {
traverse(child, depth + 1)
}
}
}
traverse(node, 0)
return stats
}
// Helper methods
private static getParentPath(path: string): string {
if (path === '/') return '/'
const parts = path.split('/')
parts.pop()
return parts.length === 1 ? '/' : parts.join('/')
}
private static sortTreeNodes(node: TreeNode, sortBy: 'name' | 'modified' | 'size'): void {
if (!node.children) return
node.children.sort((a, b) => {
// Directories first, then files
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1
}
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name)
case 'modified':
const aTime = a.metadata?.modified || 0
const bTime = b.metadata?.modified || 0
return bTime - aTime
case 'size':
const aSize = a.metadata?.size || 0
const bSize = b.metadata?.size || 0
return bSize - aSize
default:
return 0
}
})
// Recursively sort children
for (const child of node.children) {
this.sortTreeNodes(child, sortBy)
}
}
private static limitDepth(node: TreeNode, maxDepth: number, currentDepth: number = 0): void {
if (currentDepth >= maxDepth) {
delete node.children
return
}
if (node.children) {
for (const child of node.children) {
this.limitDepth(child, maxDepth, currentDepth + 1)
}
}
}
/**
* Validate tree structure - ensures no recursion
*/
static validateTree(node: TreeNode, visited: Set<string> = new Set()): {
valid: boolean
errors: string[]
} {
const errors: string[] = []
// Check for cycles
if (visited.has(node.path)) {
errors.push(`Cycle detected at path: ${node.path}`)
return { valid: false, errors }
}
visited.add(node.path)
// Check children
if (node.children) {
const childPaths = new Set<string>()
for (const child of node.children) {
// Check for duplicate children
if (childPaths.has(child.path)) {
errors.push(`Duplicate child path: ${child.path}`)
}
childPaths.add(child.path)
// Check child is not parent
if (child.path === node.path) {
errors.push(`Directory contains itself: ${node.path}`)
}
// Check child is actually under parent
if (node.path !== '/' && !child.path.startsWith(node.path + '/')) {
errors.push(`Child ${child.path} not under parent ${node.path}`)
}
// Recursively validate children
const childValidation = this.validateTree(child, new Set(visited))
errors.push(...childValidation.errors)
}
}
return {
valid: errors.length === 0,
errors
}
}
}

View file

@ -408,6 +408,154 @@ export class VirtualFileSystem implements IVirtualFileSystem {
// Knowledge Layer hooks will be added by augmentation if enabled
}
// ============= Tree Operations (NEW) =============
/**
* Get only direct children of a directory - guaranteed no self-inclusion
* This is the SAFE way to get children for building tree UIs
*/
async getDirectChildren(path: string): Promise<VFSEntity[]> {
await this.ensureInitialized()
const entityId = await this.pathResolver.resolve(path)
const entity = await this.getEntityById(entityId)
// Verify it's a directory
if (entity.metadata.vfsType !== 'directory') {
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'getDirectChildren')
}
// Use the safe getChildren from PathResolver
const children = await this.pathResolver.getChildren(entityId)
// Double-check no self-inclusion (paranoid safety)
return children.filter(child => child.metadata.path !== path)
}
/**
* Get a properly structured tree for the given path
* This prevents recursion issues common when building file explorers
*/
async getTreeStructure(path: string, options?: {
maxDepth?: number
includeHidden?: boolean
sort?: 'name' | 'modified' | 'size'
}): Promise<any> {
await this.ensureInitialized()
const { VFSTreeUtils } = await import('./TreeUtils.js')
const entityId = await this.pathResolver.resolve(path)
const entity = await this.getEntityById(entityId)
if (entity.metadata.vfsType !== 'directory') {
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'getTreeStructure')
}
// Recursively gather all descendants
const allEntities: VFSEntity[] = []
const visited = new Set<string>()
const gatherDescendants = async (dirId: string) => {
if (visited.has(dirId)) return // Prevent cycles
visited.add(dirId)
const children = await this.pathResolver.getChildren(dirId)
for (const child of children) {
allEntities.push(child)
if (child.metadata.vfsType === 'directory') {
await gatherDescendants(child.id)
}
}
}
await gatherDescendants(entityId)
// Build safe tree structure
return VFSTreeUtils.buildTree(allEntities, path, options || {})
}
/**
* Get all descendants of a directory (flat list)
*/
async getDescendants(path: string, options?: {
includeAncestor?: boolean
type?: 'file' | 'directory'
}): Promise<VFSEntity[]> {
await this.ensureInitialized()
const entityId = await this.pathResolver.resolve(path)
const entity = await this.getEntityById(entityId)
if (entity.metadata.vfsType !== 'directory') {
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'getDescendants')
}
const descendants: VFSEntity[] = []
if (options?.includeAncestor) {
descendants.push(entity)
}
const visited = new Set<string>()
const queue = [entityId]
while (queue.length > 0) {
const currentId = queue.shift()!
if (visited.has(currentId)) continue
visited.add(currentId)
const children = await this.pathResolver.getChildren(currentId)
for (const child of children) {
// Filter by type if specified
if (!options?.type || child.metadata.vfsType === options.type) {
descendants.push(child)
}
// Add directories to queue for traversal
if (child.metadata.vfsType === 'directory') {
queue.push(child.id)
}
}
}
return descendants
}
/**
* Inspect a path and return structured information
* This is the recommended method for file explorers to use
*/
async inspect(path: string): Promise<{
node: VFSEntity
children: VFSEntity[]
parent: VFSEntity | null
stats: VFSStats
}> {
await this.ensureInitialized()
const entityId = await this.pathResolver.resolve(path)
const entity = await this.getEntityById(entityId)
const stats = await this.stat(path)
let children: VFSEntity[] = []
if (entity.metadata.vfsType === 'directory') {
children = await this.getDirectChildren(path)
}
let parent: VFSEntity | null = null
if (path !== '/') {
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
const parentId = await this.pathResolver.resolve(parentPath)
parent = await this.getEntityById(parentId)
}
return {
node: entity,
children,
parent,
stats
}
}
// ============= Directory Operations =============
/**

View file

@ -386,6 +386,24 @@ export interface IVirtualFileSystem {
rmdir(path: string, options?: { recursive?: boolean }): Promise<void>
readdir(path: string, options?: ReaddirOptions): Promise<string[] | VFSDirent[]>
// Tree operations (NEW - prevents recursion issues)
getDirectChildren(path: string): Promise<VFSEntity[]>
getTreeStructure(path: string, options?: {
maxDepth?: number
includeHidden?: boolean
sort?: 'name' | 'modified' | 'size'
}): Promise<any>
getDescendants(path: string, options?: {
includeAncestor?: boolean
type?: 'file' | 'directory'
}): Promise<VFSEntity[]>
inspect(path: string): Promise<{
node: VFSEntity
children: VFSEntity[]
parent: VFSEntity | null
stats: VFSStats
}>
// Metadata operations
stat(path: string): Promise<VFSStats>
lstat(path: string): Promise<VFSStats>