diff --git a/cli/engine/node/file-system.mjs b/cli/engine/node/file-system.mjs
index aaa8c6df7..eee17ea4f 100644
--- a/cli/engine/node/file-system.mjs
+++ b/cli/engine/node/file-system.mjs
@@ -5,11 +5,24 @@ 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__',
]);
+// The exceptions to the hidden-dir rule: hidden directories that
+// conventionally hold real UI source rather than tooling or vendored code.
+// VitePress and VuePress keep custom theme components in
+// .vitepress/theme/*.vue / .vuepress/theme/, and Storybook keeps preview
+// decorators/styles in .storybook/.
+const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
+
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
@@ -24,6 +37,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('.') && !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 d296320d7..ab36da497 100644
--- a/skill/scripts/context-signals.mjs
+++ b/skill/scripts/context-signals.mjs
@@ -156,9 +156,23 @@ 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 !== '.vitepress' && seg !== '.vuepress' && seg !== '.storybook') ||
+ 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 +187,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..a5b6dc7c6 100644
--- a/tests/context-signals.test.mjs
+++ b/tests/context-signals.test.mjs
@@ -135,6 +135,56 @@ 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('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' });
+ 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..8f068faf5 100644
--- a/tests/detect-antipatterns.test.js
+++ b/tests/detect-antipatterns.test.js
@@ -1828,6 +1828,55 @@ 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');
+ // 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('.vuepress/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, '.vuepress', 'theme', 'Layout.vue'),
+ 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 });
+ }
+ });
});
// ---------------------------------------------------------------------------