fix(rul): globToRegex corrupts mid-pattern /**/ globs (M-BUG-19)

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 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-07-17 03:53:06 +02:00
commit 0cd87e0597
2 changed files with 40 additions and 2 deletions

View file

@ -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('/')) {

View file

@ -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(' | ')}`,
);
});
});