diff --git a/cli/engine/node/file-system.mjs b/cli/engine/node/file-system.mjs index 674b01137..c3f87da16 100644 --- a/cli/engine/node/file-system.mjs +++ b/cli/engine/node/file-system.mjs @@ -16,6 +16,12 @@ const SKIP_DIRS = new Set([ 'node_modules', 'dist', 'build', '__pycache__', ]); +// The exceptions to the hidden-dir rule: hidden directories that +// conventionally hold real UI source rather than tooling or vendored code. +// VitePress keeps custom theme components in .vitepress/theme/*.vue, and +// Storybook keeps preview decorators/styles in .storybook/. +const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.storybook']); + const SCANNABLE_EXTENSIONS = new Set([ '.html', '.htm', '.css', '.scss', '.sass', '.less', '.jsx', '.tsx', '.js', '.ts', @@ -30,7 +36,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; + 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)); 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 3cde76f1f..17ef2f6dd 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -167,7 +167,9 @@ const SOURCE_DIRS = ['src', 'app', 'components', 'pages', 'public']; 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__', + (seg) => + (seg.startsWith('.') && seg !== '.vitepress' && seg !== '.storybook') || + seg === 'node_modules' || seg === 'dist' || seg === 'build' || seg === '__pycache__', ); } diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index 5053b8721..a5b6dc7c6 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -152,6 +152,23 @@ describe('gatherSignals', () => { assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // harness path filtered out }); + it('keeps hidden-source-dir files (VitePress/Storybook) in scan targets', 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('.vitepress/theme/Layout.vue', '\n'); + write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 1;\n'); + git('add', '.'); + git('commit', '-qm', 'init'); + write('.vitepress/theme/Layout.vue', '\n'); // real UI source + write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 2;\n'); // vendored + const s = await gatherSignals(scratch); + assert.equal(s.scan.via, 'git-changes'); + assert.deepEqual(s.scan.targets, ['.vitepress/theme/Layout.vue']); + }); + 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' }); diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 8fb57a081..a024173b9 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -1846,8 +1846,17 @@ describe('walkDir', () => { 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')]); + // Hidden dirs that conventionally hold real UI source are the + // exception: VitePress themes and Storybook preview files must keep + // being scanned (they were before the hidden-dir rule existed). + write('.vitepress/theme/Layout.vue'); + write('.storybook/preview.css'); + const files = walkDir(tmp).sort(); + expect(files).toEqual([ + path.join(tmp, '.storybook', 'preview.css'), + path.join(tmp, '.vitepress', 'theme', 'Layout.vue'), + path.join(tmp, 'src', 'app.css'), + ]); } finally { fs.rmSync(tmp, { recursive: true, force: true }); }