From 08b03e876332937b180876051e2a5a338b1a87b0 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 21 Aug 2026 12:23:55 -0700 Subject: [PATCH] Centralize live path glob matching AI-assisted change prepared by Codex under scheduled architecture-simplification authorization from maintainer pbakaus. --- skill/scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++++++++ skill/scripts/live-inject.mjs | 44 ++------------------------- skill/scripts/live.mjs | 35 ++------------------- tests/live-inject.test.mjs | 28 +++++++++++++++++ 4 files changed, 69 insertions(+), 75 deletions(-) create mode 100644 skill/scripts/lib/live-path-globs.mjs diff --git a/skill/scripts/lib/live-path-globs.mjs b/skill/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/skill/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/skill/scripts/live-inject.mjs b/skill/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/skill/scripts/live-inject.mjs +++ b/skill/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/skill/scripts/live.mjs b/skill/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/skill/scripts/live.mjs +++ b/skill/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/tests/live-inject.test.mjs b/tests/live-inject.test.mjs index c58ba69f7..16a5ee4e9 100644 --- a/tests/live-inject.test.mjs +++ b/tests/live-inject.test.mjs @@ -10,10 +10,38 @@ import { dirname, join, relative, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; +import { livePathGlobToRegex } from '../skill/scripts/lib/live-path-globs.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const INJECT = resolve(__dirname, '..', 'skill/scripts/live-inject.mjs'); +describe('live path globs', () => { + it('matches recursive segments, including zero segments', () => { + const anywhere = livePathGlobToRegex('**/index.html'); + assert.equal(anywhere.test('index.html'), true); + assert.equal(anywhere.test('public/index.html'), true); + assert.equal(anywhere.test('apps/web/public/index.html'), true); + + const underPublic = livePathGlobToRegex('public/**/*.html'); + assert.equal(underPublic.test('public/index.html'), true); + assert.equal(underPublic.test('public/docs/index.html'), true); + assert.equal(underPublic.test('src/index.html'), false); + }); + + it('keeps single-star and question-mark matches inside one segment', () => { + const pattern = livePathGlobToRegex('pages/*/item?.html'); + assert.equal(pattern.test('pages/docs/item1.html'), true); + assert.equal(pattern.test('pages/docs/deep/item1.html'), false); + assert.equal(pattern.test('pages/docs/item12.html'), false); + }); + + it('treats regular-expression punctuation as literal path text', () => { + const pattern = livePathGlobToRegex('pages/[draft]/item+.html'); + assert.equal(pattern.test('pages/[draft]/item+.html'), true); + assert.equal(pattern.test('pages/d/itemm.html'), false); + }); +}); + function runInject(cwd, configPath, args) { try { const out = execFileSync('node', [INJECT, ...args], {