From 9f008ebf82d7140f1791955bae9277cd8338d3d2 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sat, 25 Jul 2026 17:51:12 -0700 Subject: [PATCH] Skip hidden dirs in the detector walker and vendored paths in scan targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When impeccable (or any agent tool) is installed into a project's .claude/.cursor/.codex tree, a root scan descended into the vendored skill code and reported the detector's own example strings as findings, and context-signals returned installed-skill files as scan candidates whenever the harness tree appeared in the branch diff (issue #303). Rather than growing SKIP_DIRS by a denylist of harness names that drifts as new tools appear, the walker now skips every hidden directory during recursion — which already covered .git/.next/.nuxt/.svelte-kit/.turbo/ .vercel, and covers all present and future harness installs plus .impeccable itself. SKIP_DIRS shrinks to the four non-hidden entries. An explicitly passed hidden target still scans: only child entries are name-checked, never the root the walker is given. scanTargets() applies the same rule to git-changed files (directory segments only, so root dotfiles keep their existing behavior), and falls through to source-dir targeting when the only dirty files are vendored. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- cli/engine/node/file-system.mjs | 11 +++++++-- skill/scripts/context-signals.mjs | 15 +++++++++++- tests/context-signals.test.mjs | 33 +++++++++++++++++++++++++++ tests/detect-antipatterns.test.js | 38 +++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/cli/engine/node/file-system.mjs b/cli/engine/node/file-system.mjs index aaa8c6df7..674b01137 100644 --- a/cli/engine/node/file-system.mjs +++ b/cli/engine/node/file-system.mjs @@ -5,9 +5,15 @@ import path from 'node:path'; // File walker // --------------------------------------------------------------------------- +// Hidden directories are skipped wholesale during recursion (below), which +// covers .git / .next / .nuxt / .svelte-kit / .turbo / .vercel and — the +// issue #303 class — every vendored AI-harness install (.claude, .cursor, +// .codex, .agents, .impeccable, ...) whose bundled detector source would +// otherwise be reported as findings on a root scan. Only the non-hidden +// build/dependency dirs need naming. An explicitly passed hidden target +// still scans: walkDir name-checks children, never the root it's given. const SKIP_DIRS = new Set([ - 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output', - '.svelte-kit', '__pycache__', '.turbo', '.vercel', + 'node_modules', 'dist', 'build', '__pycache__', ]); const SCANNABLE_EXTENSIONS = new Set([ @@ -24,6 +30,7 @@ function walkDir(dir) { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; } for (const entry of entries) { if (SKIP_DIRS.has(entry.name)) continue; + if (entry.isDirectory() && entry.name.startsWith('.')) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) files.push(...walkDir(full)); else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full); diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index d296320d7..3cde76f1f 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -156,9 +156,21 @@ const SCANNABLE_EXT = new Set([ '.jsx', '.tsx', '.js', '.ts', '.vue', '.svelte', '.astro', ]); // Where UI source typically lives. The detector walks these and skips -// node_modules / dist / build / .next / .nuxt automatically. +// node_modules / dist / build and all hidden dirs automatically. const SOURCE_DIRS = ['src', 'app', 'components', 'pages', 'public']; +// A changed file under a hidden or dependency/build directory is not app +// source — it's a vendored AI-harness install (.claude/skills/..., .cursor/, +// .impeccable/, issue #303), a build artifact, or a dependency. Mirrors the +// engine walkDir's skip rule so git-changes targeting can't resurface paths +// the walker would never visit. +function isVendoredPath(rel) { + const dirSegments = rel.split(/[\\/]/).slice(0, -1); + return dirSegments.some( + (seg) => seg.startsWith('.') || seg === 'node_modules' || seg === 'dist' || seg === 'build' || seg === '__pycache__', + ); +} + /** * Local paths the agent should point the bundled detector at — never a URL. * A URL means a costly Puppeteer browser render, and a probed dev-server port @@ -173,6 +185,7 @@ function scanTargets(cwd, git) { if (git.isRepo && git.changedFiles.length) { const changed = git.changedFiles .filter((f) => SCANNABLE_EXT.has(path.extname(f).toLowerCase())) + .filter((f) => !isVendoredPath(f)) .filter((f) => fs.existsSync(path.join(cwd, f))); if (changed.length) return { targets: changed.slice(0, 50), via: 'git-changes' }; } diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index 165494140..5053b8721 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -135,6 +135,39 @@ describe('gatherSignals', () => { assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // README.md filtered out }); + it('filters harness-dir files out of git-changes scan targets (#303)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q'); + git('config', 'user.email', 't@example.com'); + git('config', 'user.name', 'Test'); + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 1;\n'); + git('add', '.'); + git('commit', '-qm', 'init'); + write('src/Hero.tsx', 'export const Hero = () => 2;\n'); // dirty app code + write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 2;\n'); // dirty vendored skill + const s = await gatherSignals(scratch); + assert.equal(s.scan.via, 'git-changes'); + assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // harness path filtered out + }); + + it('falls through to source dirs when only harness files changed (#303)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q'); + git('config', 'user.email', 't@example.com'); + git('config', 'user.name', 'Test'); + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + write('.cursor/skills/impeccable/example.css', 'a{}\n'); + git('add', '.'); + git('commit', '-qm', 'init'); + write('.cursor/skills/impeccable/example.css', 'a{color:red}\n'); // only harness dirty + const s = await gatherSignals(scratch); + assert.equal(s.scan.via, 'source-dir'); + assert.deepEqual(s.scan.targets, ['src']); + }); + it('has empty scan.targets only when there is no code at all', async () => { const s = await gatherSignals(scratch); assert.deepEqual(s.scan.targets, []); diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 6d837422e..8fb57a081 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -1828,6 +1828,44 @@ describe('walkDir', () => { test('returns empty for nonexistent dir', () => { expect(walkDir('/nonexistent/path/12345')).toHaveLength(0); }); + + // 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 + // findings. Hidden directories are never app source — skip them all. + test('skips hidden dirs (AI-harness installs) during recursion', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-walk-')); + try { + const write = (rel) => { + const abs = path.join(tmp, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, '/* fixture */'); + }; + write('src/app.css'); + write('.claude/skills/impeccable/scripts/detector.js'); + write('.cursor/skills/impeccable/example.css'); + write('.impeccable/live/preview.html'); + write('node_modules/pkg/index.js'); + const files = walkDir(tmp); + expect(files).toEqual([path.join(tmp, 'src', 'app.css')]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('still scans a hidden dir passed as the explicit target', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-walk-')); + try { + const abs = path.join(tmp, '.claude', 'page.html'); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, ''); + // Only child entries are name-checked; naming the hidden dir directly + // is an explicit user intent and must keep working. + expect(walkDir(path.join(tmp, '.claude'))).toEqual([abs]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); }); // ---------------------------------------------------------------------------