From 0cd87e059758f7173deea9ab398b9df0860c1503 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 17 Jul 2026 03:53:06 +0200 Subject: [PATCH] fix(rul): globToRegex corrupts mid-pattern /**/ globs (M-BUG-19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ? -> [^/] replacement ran AFTER the {{GLOBSTAR_SLASH}} placeholder was restored to '(?:/.+/|/)', corrupting the group opener '(?:' into '([^/]:' — every rule pattern containing a mid-pattern '/**/' silently matched only the zero-dir branch and live rules were flagged 'matches no files' (CA-RUL). Found by dogfooding /config-audit implement on a throwaway repo copy: the implementer agent's correct 'posts/**/post.md' rule was flagged dead. Fix: run the ? replacement before placeholder restoration. Fixture outcomes byte-identical; frozen v5.0.0 baselines untouched. Co-Authored-By: Claude Fable 5 --- scanners/rules-validator.mjs | 4 +-- tests/scanners/rules-validator.test.mjs | 38 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) 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(' | ')}`, + ); + }); +});