diff --git a/scanners/rules-validator.mjs b/scanners/rules-validator.mjs index e277132..4a1b61d 100644 --- a/scanners/rules-validator.mjs +++ b/scanners/rules-validator.mjs @@ -251,9 +251,9 @@ function globToRegex(pattern) { .replace(/\/\*\*\//g, '{{GLOBSTAR_SLASH}}') .replace(/\*\*/g, '{{GLOBSTAR}}') .replace(/\*/g, '[^/]*') + .replace(/\?/g, '[^/]') // must run BEFORE placeholder restore — '(?:' would corrupt .replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(?:/.+/|/)') // **/ matches 0+ intermediate dirs - .replace(/\{\{GLOBSTAR\}\}/g, '.*') - .replace(/\?/g, '[^/]'); + .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // Handle leading patterns if (!regex.startsWith('.*') && !regex.startsWith('/')) { diff --git a/tests/scanners/rules-validator.test.mjs b/tests/scanners/rules-validator.test.mjs index 4cf9390..cfa0d0d 100644 --- a/tests/scanners/rules-validator.test.mjs +++ b/tests/scanners/rules-validator.test.mjs @@ -291,3 +291,41 @@ describe('RUL — user-global rule is not flagged "matches no files" (M-BUG-9 gu assert.equal(dead.length, 0, `user-global rule wrongly flagged dead: ${dead.map(f => f.evidence).join(' | ')}`); }); }); + +describe('RUL — mid-pattern /**/ glob matches intermediate dirs (M-BUG-19)', () => { + // globToRegex restored the {{GLOBSTAR_SLASH}} placeholder to "(?:/.+/|/)" + // BEFORE the ? → [^/] replacement ran, corrupting the group opener "(?:" + // into "([^/]:" — so every pattern containing a mid-pattern "/**/" silently + // matched nothing but the zero-dir "|/" branch, and live rules like + // "posts/**/post.md" were flagged "matches no files". + let tmpRoot; + let result; + + beforeEach(async () => { + resetCounter(); + tmpRoot = await mkdtemp(join(tmpdir(), 'ca-rul-globstar-')); + await mkdir(join(tmpRoot, '.claude', 'rules'), { recursive: true }); + await mkdir(join(tmpRoot, 'posts', '2026-01-23-slug'), { recursive: true }); + await writeFile(join(tmpRoot, 'posts', '2026-01-23-slug', 'post.md'), '# Post\n', 'utf8'); + await writeFile( + join(tmpRoot, '.claude', 'rules', 'post-scope.md'), + '---\npaths: "posts/**/post.md"\n---\n\n# Post rule\nbody\n', + 'utf8', + ); + const discovery = await discoverConfigFiles(tmpRoot); + result = await scan(tmpRoot, discovery); + }); + + afterEach(async () => { + if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true }); + }); + + it('does NOT flag a live posts/**/post.md rule as "matches no files"', () => { + const dead = result.findings.filter(f => f.title.includes('matches no files')); + assert.equal( + dead.length, + 0, + `/**/ rule matched a real file but was flagged dead: ${dead.map(f => f.evidence).join(' | ')}`, + ); + }); +});