diff --git a/tools/readiness-core/src/index.ts b/tools/readiness-core/src/index.ts index 739c999e..e20b7acf 100644 --- a/tools/readiness-core/src/index.ts +++ b/tools/readiness-core/src/index.ts @@ -39,10 +39,19 @@ export async function scanSkillDirectories(root: string): Promise { const foundSet = new Set(); for (const dir of dirs) { if (!(await fileExists(dir))) continue; - const entries = await readdir(dir, { withFileTypes: true }); - for (const e of entries) { - if (e.isDirectory()) foundSet.add(e.name); - if (e.isFile() && e.name === 'SKILL.md') foundSet.add('root-skill'); + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory()) foundSet.add(e.name); + if (e.isFile() && e.name === 'SKILL.md') foundSet.add('root-skill'); + } + } catch { + // fileExists only proves the path existed at stat time -- it can still + // turn out to not be a directory (ENOTDIR), be unreadable (EPERM), or + // be removed between the two calls (ENOENT/TOCTOU). Any of those used + // to propagate out of this function uncaught, aborting the whole + // audit run in loop-audit/goal-audit instead of just skipping this one + // path. Matches tools/mcp-server/src/resolver.ts's listSkills(). } } return [...foundSet]; diff --git a/tools/readiness-core/test/index.test.mjs b/tools/readiness-core/test/index.test.mjs index 1252f5f1..7943d283 100644 --- a/tools/readiness-core/test/index.test.mjs +++ b/tools/readiness-core/test/index.test.mjs @@ -74,3 +74,21 @@ test('scanSkillDirectories finds skills in target directory', async () => { await fs.rm(fixtureDir, { recursive: true, force: true }); }); + +test('scanSkillDirectories skips a skills path that is not a directory instead of throwing', async () => { + // fileExists() only proves the path existed at stat() time -- it doesn't + // prove readdir() will succeed on it. A `skills` path that's actually a + // file (renamed, a broken checkout, a stray artifact) used to crash the + // whole scan with an uncaught ENOTDIR, aborting the entire audit run in + // loop-audit/goal-audit instead of just skipping this one path. + const fixtureDir = path.join(process.cwd(), '.test-fixture-not-a-dir'); + await fs.rm(fixtureDir, { recursive: true, force: true }); + await fs.mkdir(path.join(fixtureDir, '.claude'), { recursive: true }); + await fs.writeFile(path.join(fixtureDir, '.claude', 'skills'), 'not actually a directory'); + await fs.mkdir(path.join(fixtureDir, 'skills', 'bar'), { recursive: true }); + + const skills = await scanSkillDirectories(fixtureDir); + assert.ok(skills.includes('bar'), 'A valid skills dir is still scanned despite a broken sibling path'); + + await fs.rm(fixtureDir, { recursive: true, force: true }); +});