From 52380df1ada19fccbf665ca11809d2a9cf065b68 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 2 Sep 2026 19:12:03 -0700 Subject: [PATCH] Report unreadable detector directories AI assistance disclosure: Codex implemented and tested this fix under maintainer direction. --- cli/engine/cli/main.mjs | 2 +- cli/engine/node/file-system.mjs | 11 +++++-- tests/detect-antipatterns.test.js | 15 +++++++++ tests/detect-url-launch.test.mjs | 52 ++++++++++++++++++++++++++++--- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/cli/engine/cli/main.mjs b/cli/engine/cli/main.mjs index de3e71991..663b7e860 100644 --- a/cli/engine/cli/main.mjs +++ b/cli/engine/cli/main.mjs @@ -401,7 +401,7 @@ async function detectCli() { } } - const files = walkDir(resolved) + const files = walkDir(resolved, reportLocalScanFailure) .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; diff --git a/cli/engine/node/file-system.mjs b/cli/engine/node/file-system.mjs index 7fe2c866a..654705a89 100644 --- a/cli/engine/node/file-system.mjs +++ b/cli/engine/node/file-system.mjs @@ -46,15 +46,20 @@ const IMPORT_SPECIFIER_PATTERNS = [ /@(?:use|forward)\s+['"]([^'"]+)['"]/g, ]; -function walkDir(dir) { +function walkDir(dir, onReadError = null) { const files = []; let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; } + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (error) { + if (onReadError) onReadError(dir, error); + return files; + } for (const entry of entries) { if (SKIP_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; const full = path.join(dir, entry.name); - if (entry.isDirectory()) files.push(...walkDir(full)); + if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); else if (hasScannableExtension(entry.name)) files.push(full); } return files; diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 1f45641eb..786ac3a4b 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -2648,6 +2648,21 @@ describe('walkDir', () => { expect(walkDir('/nonexistent/path/12345')).toHaveLength(0); }); + test('reports directory traversal errors to an optional handler', () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-walk-error-')); + try { + const missing = path.join(parent, 'missing'); + const errors = []; + + expect(walkDir(missing, (dir, error) => { + errors.push({ dir, code: error.code }); + })).toHaveLength(0); + expect(errors).toEqual([{ dir: missing, code: 'ENOENT' }]); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } + }); + // Issue #303: when impeccable (or any agent tool) is installed into a // project's .claude/.cursor/etc. tree, a root scan descended into the // vendored skill code and reported the detector's own example strings as diff --git a/tests/detect-url-launch.test.mjs b/tests/detect-url-launch.test.mjs index a3adacc38..c304f1863 100644 --- a/tests/detect-url-launch.test.mjs +++ b/tests/detect-url-launch.test.mjs @@ -67,9 +67,10 @@ function runWithoutPuppeteer(args, files = {}, { nodeArgs = [] } = {}) { } } -const DENY_READ_PRELOAD = ` +const DENY_FS_PRELOAD = ` const fs = require('node:fs'); const originalReadFileSync = fs.readFileSync; +const originalReaddirSync = fs.readdirSync; fs.readFileSync = function (file, ...args) { if (String(file).endsWith('unreadable.css')) { const error = new Error('simulated EACCES'); @@ -78,6 +79,14 @@ fs.readFileSync = function (file, ...args) { } return originalReadFileSync.call(this, file, ...args); }; +fs.readdirSync = function (dir, ...args) { + if (String(dir).endsWith('unreadable-dir')) { + const error = new Error('simulated directory EACCES'); + error.code = 'EACCES'; + throw error; + } + return originalReaddirSync.call(this, dir, ...args); +}; `; describe('launchBrowser', () => { @@ -176,10 +185,10 @@ describe('detect CLI browser failures', () => { const result = runWithoutPuppeteer( ['unreadable.css'], { - 'deny-read.cjs': DENY_READ_PRELOAD, + 'deny-fs.cjs': DENY_FS_PRELOAD, 'unreadable.css': '.hero { color: red; }\n', }, - { nodeArgs: ['--require=./deny-read.cjs'] }, + { nodeArgs: ['--require=./deny-fs.cjs'] }, ); expect(result.status).toBe(1); @@ -192,11 +201,11 @@ describe('detect CLI browser failures', () => { const result = runWithoutPuppeteer( ['styles'], { - 'deny-read.cjs': DENY_READ_PRELOAD, + 'deny-fs.cjs': DENY_FS_PRELOAD, 'styles/unreadable.css': '.hero { color: red; }\n', 'styles/page.css': '.hero { animation: bounce 1s linear infinite; }\n', }, - { nodeArgs: ['--require=./deny-read.cjs'] }, + { nodeArgs: ['--require=./deny-fs.cjs'] }, ); const findings = JSON.parse(result.stdout); @@ -204,6 +213,39 @@ describe('detect CLI browser failures', () => { expect(findings.some(finding => finding.antipattern === 'bounce-easing')).toBe(true); expect(result.stderr.match(/simulated EACCES/g)).toHaveLength(1); }); + + test('unreadable local directory exits 1 with valid empty JSON', () => { + const result = runWithoutPuppeteer( + ['unreadable-dir'], + { + 'deny-fs.cjs': DENY_FS_PRELOAD, + 'unreadable-dir/page.css': '.hero { color: red; }\n', + }, + { nodeArgs: ['--require=./deny-fs.cjs'] }, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toBe('[]\n'); + expect(result.stderr).toContain('Error: cannot scan'); + expect(result.stderr).toContain('unreadable-dir: simulated directory EACCES'); + }); + + test('unreadable nested directory preserves findings from readable siblings', () => { + const result = runWithoutPuppeteer( + ['project'], + { + 'deny-fs.cjs': DENY_FS_PRELOAD, + 'project/unreadable-dir/hidden.css': '.hero { color: red; }\n', + 'project/page.css': '.hero { animation: bounce 1s linear infinite; }\n', + }, + { nodeArgs: ['--require=./deny-fs.cjs'] }, + ); + const findings = JSON.parse(result.stdout); + + expect(result.status).toBe(1); + expect(findings.some(finding => finding.antipattern === 'bounce-easing')).toBe(true); + expect(result.stderr.match(/simulated directory EACCES/g)).toHaveLength(1); + }); }); describe('splitScanUrl', () => {