diff --git a/.changeset/fix-symlink-directory-detection.md b/.changeset/fix-symlink-directory-detection.md new file mode 100644 index 000000000..ff7ec6088 --- /dev/null +++ b/.changeset/fix-symlink-directory-detection.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +fix: Symlinked directories are now correctly detected when scanning `openspec/schemas/`, `openspec/specs/`, `openspec/changes/`, and artifact output subdirectories. Monorepo setups that symlink directories into these locations would get silent failures (e.g., "Unknown schema" errors, missing specs/changes in listings) because `Dirent.isDirectory()` returns `false` for symlinks, causing those entries to be skipped during discovery. diff --git a/src/commands/change.ts b/src/commands/change.ts index 051b4697c..759f5258f 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -6,6 +6,7 @@ import { ChangeParser } from '../core/parsers/change-parser.js'; import { Change } from '../core/schemas/index.js'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds } from '../utils/item-discovery.js'; +import { isDirectoryEntrySync } from '../utils/file-system.js'; // Constants for better maintainability const ARCHIVE_DIR = 'archive'; @@ -244,7 +245,7 @@ export class ChangeCommand { const entries = await fs.readdir(changesPath, { withFileTypes: true }); const result: string[] = []; for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === ARCHIVE_DIR) continue; + if (!isDirectoryEntrySync(entry, changesPath) || entry.name.startsWith('.') || entry.name === ARCHIVE_DIR) continue; const proposalPath = path.join(changesPath, entry.name, 'proposal.md'); try { await fs.access(proposalPath); diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 7f8d0b788..b9a18d8f2 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -11,6 +11,7 @@ import { listSchemas, } from '../core/artifact-graph/resolver.js'; import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js'; +import { isDirectoryEntrySync } from '../utils/file-system.js'; import type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js'; /** @@ -241,7 +242,7 @@ function copyDirRecursive(src: string, dest: string): void { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); - if (entry.isDirectory()) { + if (isDirectoryEntrySync(entry, src)) { copyDirRecursive(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); @@ -437,7 +438,7 @@ export function registerSchemaCommand(program: Command): void { let anyInvalid = false; for (const entry of entries) { - if (!entry.isDirectory()) continue; + if (!isDirectoryEntrySync(entry, projectSchemasDir)) continue; const schemaDir = path.join(projectSchemasDir, entry.name); const schemaPath = path.join(schemaDir, 'schema.yaml'); diff --git a/src/commands/spec.ts b/src/commands/spec.ts index d28052f14..306af7348 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -6,6 +6,7 @@ import { Validator } from '../core/validation/validator.js'; import type { Spec } from '../core/schemas/index.js'; import { isInteractive } from '../utils/interactive.js'; import { getSpecIds } from '../utils/item-discovery.js'; +import { isDirectoryEntrySync } from '../utils/file-system.js'; const SPECS_DIR = 'openspec/specs'; @@ -149,7 +150,7 @@ export function registerSpecCommand(rootProgram: typeof program) { } const specs = readdirSync(SPECS_DIR, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) + .filter(dirent => isDirectoryEntrySync(dirent, SPECS_DIR)) .map(dirent => { const specPath = join(SPECS_DIR, dirent.name, 'spec.md'); if (existsSync(specPath)) { diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 0d501afec..43c781a6c 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -8,6 +8,7 @@ import ora from 'ora'; import path from 'path'; import * as fs from 'fs'; +import { isDirectoryEntrySync, isFileEntrySync } from '../../utils/file-system.js'; import { loadChangeContext, generateInstructions, @@ -275,12 +276,12 @@ function artifactOutputExists(changeDir: string, generates: string): boolean { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { - if (entry.isDirectory()) { + if (isDirectoryEntrySync(entry, dir)) { // For ** patterns, recurse into subdirectories if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) { return true; } - } else if (entry.isFile()) { + } else if (isFileEntrySync(entry, dir)) { // Check if file matches expected extension (or any file if no extension specified) if (!expectedExt || entry.name.endsWith(expectedExt)) { return true; diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index a85fcd585..376022ec2 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -8,6 +8,7 @@ import chalk from 'chalk'; import path from 'path'; import * as fs from 'fs'; +import { isDirectoryEntrySync } from '../../utils/file-system.js'; import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js'; import { validateChangeName } from '../../utils/change-utils.js'; @@ -95,7 +96,7 @@ export async function getAvailableChanges(projectRoot: string): Promise e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.')) + .filter((e) => isDirectoryEntrySync(e, changesPath) && e.name !== 'archive' && !e.name.startsWith('.')) .map((e) => e.name); } catch (error: unknown) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; diff --git a/src/core/archive.ts b/src/core/archive.ts index 5af7181fc..9f8fcff44 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -1,5 +1,6 @@ import { promises as fs } from 'fs'; import path from 'path'; +import { isDirectoryEntrySync } from '../utils/file-system.js'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { Validator } from './validation/validator.js'; import chalk from 'chalk'; @@ -19,7 +20,7 @@ async function copyDirRecursive(src: string, dest: string): Promise { for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); - if (entry.isDirectory()) { + if (isDirectoryEntrySync(entry, src)) { await copyDirRecursive(srcPath, destPath); } else { await fs.copyFile(srcPath, destPath); @@ -116,7 +117,7 @@ export class ArchiveCommand { try { const candidates = await fs.readdir(changeSpecsDir, { withFileTypes: true }); for (const c of candidates) { - if (c.isDirectory()) { + if (isDirectoryEntrySync(c, changeSpecsDir)) { try { const candidatePath = path.join(changeSpecsDir, c.name, 'spec.md'); await fs.access(candidatePath); @@ -292,7 +293,7 @@ export class ArchiveCommand { // Get all directories in changes (excluding archive) const entries = await fs.readdir(changesDir, { withFileTypes: true }); const changeDirs = entries - .filter(entry => entry.isDirectory() && entry.name !== 'archive') + .filter(entry => isDirectoryEntrySync(entry, changesDir) && entry.name !== 'archive') .map(entry => entry.name) .sort(); diff --git a/src/core/artifact-graph/resolver.ts b/src/core/artifact-graph/resolver.ts index 9ccd48aba..108c2f8f9 100644 --- a/src/core/artifact-graph/resolver.ts +++ b/src/core/artifact-graph/resolver.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { getGlobalDataDir } from '../global-config.js'; +import { isDirectoryEntrySync } from '../../utils/file-system.js'; import { parseSchema, SchemaValidationError } from './schema.js'; import type { SchemaYaml } from './types.js'; @@ -165,7 +166,7 @@ export function listSchemas(projectRoot?: string): string[] { const packageDir = getPackageSchemasDir(); if (fs.existsSync(packageDir)) { for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isDirectoryEntrySync(entry, packageDir)) { const schemaPath = path.join(packageDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); @@ -178,7 +179,7 @@ export function listSchemas(projectRoot?: string): string[] { const userDir = getUserSchemasDir(); if (fs.existsSync(userDir)) { for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isDirectoryEntrySync(entry, userDir)) { const schemaPath = path.join(userDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); @@ -192,7 +193,7 @@ export function listSchemas(projectRoot?: string): string[] { const projectDir = getProjectSchemasDir(projectRoot); if (fs.existsSync(projectDir)) { for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isDirectoryEntrySync(entry, projectDir)) { const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); @@ -230,7 +231,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const projectDir = getProjectSchemasDir(projectRoot); if (fs.existsSync(projectDir)) { for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isDirectoryEntrySync(entry, projectDir)) { const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { @@ -255,7 +256,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const userDir = getUserSchemasDir(); if (fs.existsSync(userDir)) { for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) { - if (entry.isDirectory() && !seenNames.has(entry.name)) { + if (isDirectoryEntrySync(entry, userDir) && !seenNames.has(entry.name)) { const schemaPath = path.join(userDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { @@ -279,7 +280,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const packageDir = getPackageSchemasDir(); if (fs.existsSync(packageDir)) { for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) { - if (entry.isDirectory() && !seenNames.has(entry.name)) { + if (isDirectoryEntrySync(entry, packageDir) && !seenNames.has(entry.name)) { const schemaPath = path.join(packageDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { diff --git a/src/core/list.ts b/src/core/list.ts index 3f40829a6..7c3c6d7a6 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -1,5 +1,6 @@ import { promises as fs } from 'fs'; import path from 'path'; +import { isDirectoryEntrySync } from '../utils/file-system.js'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { readFileSync } from 'fs'; import { join } from 'path'; @@ -28,7 +29,7 @@ async function getLastModified(dirPath: string): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { + if (isDirectoryEntrySync(entry, dir)) { await walk(fullPath); } else { const stat = await fs.stat(fullPath); @@ -91,7 +92,7 @@ export class ListCommand { // Get all directories in changes (excluding archive) const entries = await fs.readdir(changesDir, { withFileTypes: true }); const changeDirs = entries - .filter(entry => entry.isDirectory() && entry.name !== 'archive') + .filter(entry => isDirectoryEntrySync(entry, changesDir) && entry.name !== 'archive') .map(entry => entry.name); if (changeDirs.length === 0) { @@ -161,7 +162,7 @@ export class ListCommand { } const entries = await fs.readdir(specsDir, { withFileTypes: true }); - const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name); + const specDirs = entries.filter(e => isDirectoryEntrySync(e, specsDir)).map(e => e.name); if (specDirs.length === 0) { console.log('No specs found.'); return; diff --git a/src/core/parsers/change-parser.ts b/src/core/parsers/change-parser.ts index 0c8d1e280..baf07653f 100644 --- a/src/core/parsers/change-parser.ts +++ b/src/core/parsers/change-parser.ts @@ -2,6 +2,7 @@ import { MarkdownParser, Section } from './markdown-parser.js'; import { Change, Delta, DeltaOperation, Requirement } from '../schemas/index.js'; import path from 'path'; import { promises as fs } from 'fs'; +import { isDirectoryEntrySync } from '../../utils/file-system.js'; interface DeltaSection { operation: DeltaOperation; @@ -59,7 +60,7 @@ export class ChangeParser extends MarkdownParser { const specDirs = await fs.readdir(specsDir, { withFileTypes: true }); for (const dir of specDirs) { - if (!dir.isDirectory()) continue; + if (!isDirectoryEntrySync(dir, specsDir)) continue; const specName = dir.name; const specFile = path.join(specsDir, specName, 'spec.md'); diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 9ce0f12f4..30f407455 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -8,6 +8,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import chalk from 'chalk'; +import { isDirectoryEntrySync } from '../utils/file-system.js'; import { extractRequirementsSection, parseDeltaSpec, @@ -61,7 +62,7 @@ export async function findSpecUpdates(changeDir: string, mainSpecsDir: string): const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true }); for (const entry of entries) { - if (entry.isDirectory()) { + if (isDirectoryEntrySync(entry, changeSpecsDir)) { const specFile = path.join(changeSpecsDir, entry.name, 'spec.md'); const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md'); diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index e6928cbda..20b5ac1dd 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -11,7 +11,7 @@ import { VALIDATION_MESSAGES } from './constants.js'; import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js'; -import { FileSystemUtils } from '../../utils/file-system.js'; +import { FileSystemUtils, isDirectoryEntrySync } from '../../utils/file-system.js'; export class Validator { private strictMode: boolean; @@ -121,7 +121,7 @@ export class Validator { try { const entries = await fs.readdir(specsDir, { withFileTypes: true }); for (const entry of entries) { - if (!entry.isDirectory()) continue; + if (!isDirectoryEntrySync(entry, specsDir)) continue; const specName = entry.name; const specFile = path.join(specsDir, specName, 'spec.md'); let content: string | undefined; diff --git a/src/core/view.ts b/src/core/view.ts index e67c35268..66c8838e5 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import chalk from 'chalk'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { MarkdownParser } from './parsers/markdown-parser.js'; +import { isDirectoryEntrySync } from '../utils/file-system.js'; export class ViewCommand { async execute(targetPath: string = '.'): Promise { @@ -96,7 +97,7 @@ export class ViewCommand { const entries = fs.readdirSync(changesDir, { withFileTypes: true }); for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'archive') { + if (isDirectoryEntrySync(entry, changesDir) && entry.name !== 'archive') { const progress = await getTaskProgressForChange(changesDir, entry.name); if (progress.total === 0) { @@ -140,7 +141,7 @@ export class ViewCommand { const entries = fs.readdirSync(specsDir, { withFileTypes: true }); for (const entry of entries) { - if (entry.isDirectory()) { + if (isDirectoryEntrySync(entry, specsDir)) { const specFile = path.join(specsDir, entry.name, 'spec.md'); if (fs.existsSync(specFile)) { diff --git a/src/utils/file-system.ts b/src/utils/file-system.ts index 5d98dffc1..9f5e42b9d 100644 --- a/src/utils/file-system.ts +++ b/src/utils/file-system.ts @@ -1,4 +1,5 @@ import { promises as fs, constants as fsConstants } from 'fs'; +import { statSync, type Dirent } from 'node:fs'; import path from 'path'; function isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean { @@ -41,6 +42,54 @@ function findMarkerIndex( return -1; } +/** + * Checks if a directory entry represents a directory, following symlinks. + * `Dirent.isDirectory()` returns false for symlinks, so symlinked directories + * are silently skipped when scanning with `readdirSync({ withFileTypes: true })`. + * This function resolves symlinks via `statSync` to detect the actual target type. + * + * @param entry - A directory entry from `readdirSync` or `readdir` with `{ withFileTypes: true }` + * @param parentDir - The directory that was scanned (needed to resolve the full path) + * @returns `true` if the entry is a directory or a symlink pointing to a directory + */ +export function isDirectoryEntrySync(entry: Dirent, parentDir: string): boolean { + if (entry.isDirectory()) return true; + if (entry.isSymbolicLink()) { + try { + return statSync(path.join(parentDir, entry.name)).isDirectory(); + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.debug(`Unable to resolve symlink ${path.join(parentDir, entry.name)}: ${error.message}`); + } + return false; + } + } + return false; +} + +/** + * Checks if a directory entry represents a file, following symlinks. + * Counterpart to {@link isDirectoryEntrySync} for file detection. + * + * @param entry - A directory entry from `readdirSync` or `readdir` with `{ withFileTypes: true }` + * @param parentDir - The directory that was scanned (needed to resolve the full path) + * @returns `true` if the entry is a file or a symlink pointing to a file + */ +export function isFileEntrySync(entry: Dirent, parentDir: string): boolean { + if (entry.isFile()) return true; + if (entry.isSymbolicLink()) { + try { + return statSync(path.join(parentDir, entry.name)).isFile(); + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.debug(`Unable to resolve symlink ${path.join(parentDir, entry.name)}: ${error.message}`); + } + return false; + } + } + return false; +} + export class FileSystemUtils { /** * Converts a path to use forward slashes (POSIX style). diff --git a/src/utils/index.ts b/src/utils/index.ts index e77ddf476..93befc0ce 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -12,7 +12,7 @@ export { } from './change-metadata.js'; // File system utilities -export { FileSystemUtils, removeMarkerBlock } from './file-system.js'; +export { FileSystemUtils, removeMarkerBlock, isDirectoryEntrySync, isFileEntrySync } from './file-system.js'; // Command reference utilities export { transformToHyphenCommands } from './command-references.js'; \ No newline at end of file diff --git a/src/utils/item-discovery.ts b/src/utils/item-discovery.ts index 1a86c3aed..5792b511c 100644 --- a/src/utils/item-discovery.ts +++ b/src/utils/item-discovery.ts @@ -1,5 +1,6 @@ import { promises as fs } from 'fs'; import path from 'path'; +import { isDirectoryEntrySync } from './file-system.js'; export async function getActiveChangeIds(root: string = process.cwd()): Promise { const changesPath = path.join(root, 'openspec', 'changes'); @@ -7,7 +8,7 @@ export async function getActiveChangeIds(root: string = process.cwd()): Promise< const entries = await fs.readdir(changesPath, { withFileTypes: true }); const result: string[] = []; for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive') continue; + if (!isDirectoryEntrySync(entry, changesPath) || entry.name.startsWith('.') || entry.name === 'archive') continue; const proposalPath = path.join(changesPath, entry.name, 'proposal.md'); try { await fs.access(proposalPath); @@ -28,7 +29,7 @@ export async function getSpecIds(root: string = process.cwd()): Promise { let testDir: string; @@ -255,6 +256,98 @@ describe('FileSystemUtils', () => { }); }); + describe('isDirectoryEntrySync', () => { + it('should return true for a real directory', async () => { + await fs.mkdir(path.join(testDir, 'real-dir')); + + const entries = readdirSync(testDir, { withFileTypes: true }); + const entry = entries.find(e => e.name === 'real-dir')!; + expect(isDirectoryEntrySync(entry, testDir)).toBe(true); + }); + + it('should return false for a file', async () => { + await fs.writeFile(path.join(testDir, 'file.txt'), 'content'); + + const entries = readdirSync(testDir, { withFileTypes: true }); + const entry = entries.find(e => e.name === 'file.txt')!; + expect(isDirectoryEntrySync(entry, testDir)).toBe(false); + }); + + it.skipIf(process.platform === 'win32')('should return true for a symlink to a directory', async () => { + const realDir = path.join(testDir, 'real-dir'); + await fs.mkdir(realDir); + await fs.symlink(realDir, path.join(testDir, 'link-dir')); + + const entries = readdirSync(testDir, { withFileTypes: true }); + const entry = entries.find(e => e.name === 'link-dir')!; + expect(isDirectoryEntrySync(entry, testDir)).toBe(true); + }); + + it.skipIf(process.platform === 'win32')('should return false for a symlink to a file', async () => { + const realFile = path.join(testDir, 'real-file.txt'); + await fs.writeFile(realFile, 'content'); + await fs.symlink(realFile, path.join(testDir, 'link-file')); + + const entries = readdirSync(testDir, { withFileTypes: true }); + const entry = entries.find(e => e.name === 'link-file')!; + expect(isDirectoryEntrySync(entry, testDir)).toBe(false); + }); + + it.skipIf(process.platform === 'win32')('should return false for a broken symlink', async () => { + await fs.symlink(path.join(testDir, 'nonexistent'), path.join(testDir, 'broken-link')); + + const entries = readdirSync(testDir, { withFileTypes: true }); + const entry = entries.find(e => e.name === 'broken-link')!; + expect(isDirectoryEntrySync(entry, testDir)).toBe(false); + }); + }); + + describe('isFileEntrySync', () => { + it('should return true for a real file', async () => { + await fs.writeFile(path.join(testDir, 'file.txt'), 'content'); + + const entries = readdirSync(testDir, { withFileTypes: true }); + const entry = entries.find(e => e.name === 'file.txt')!; + expect(isFileEntrySync(entry, testDir)).toBe(true); + }); + + it('should return false for a directory', async () => { + await fs.mkdir(path.join(testDir, 'dir')); + + const entries = readdirSync(testDir, { withFileTypes: true }); + const entry = entries.find(e => e.name === 'dir')!; + expect(isFileEntrySync(entry, testDir)).toBe(false); + }); + + it.skipIf(process.platform === 'win32')('should return true for a symlink to a file', async () => { + const realFile = path.join(testDir, 'real-file.txt'); + await fs.writeFile(realFile, 'content'); + await fs.symlink(realFile, path.join(testDir, 'link-file')); + + const entries = readdirSync(testDir, { withFileTypes: true }); + const entry = entries.find(e => e.name === 'link-file')!; + expect(isFileEntrySync(entry, testDir)).toBe(true); + }); + + it.skipIf(process.platform === 'win32')('should return false for a symlink to a directory', async () => { + const realDir = path.join(testDir, 'real-dir'); + await fs.mkdir(realDir); + await fs.symlink(realDir, path.join(testDir, 'link-dir')); + + const entries = readdirSync(testDir, { withFileTypes: true }); + const entry = entries.find(e => e.name === 'link-dir')!; + expect(isFileEntrySync(entry, testDir)).toBe(false); + }); + + it.skipIf(process.platform === 'win32')('should return false for a broken symlink', async () => { + await fs.symlink(path.join(testDir, 'nonexistent'), path.join(testDir, 'broken-link')); + + const entries = readdirSync(testDir, { withFileTypes: true }); + const entry = entries.find(e => e.name === 'broken-link')!; + expect(isFileEntrySync(entry, testDir)).toBe(false); + }); + }); + describe('joinPath', () => { it('should join POSIX-style paths', () => { const result = FileSystemUtils.joinPath(