diff --git a/tools/path-normalization.mjs b/tools/path-normalization.mjs new file mode 100644 index 00000000..f5b9d86e --- /dev/null +++ b/tools/path-normalization.mjs @@ -0,0 +1,84 @@ +// tools/path-normalization.mjs +/** + * Path normalization — router-gate v4 spec §3.1.1. + * + glob-matcher util (used by skill-scope-verifier, tdd-real-test-verifier). + * Pure-core; I/O (realpath) injected via opts for testability. + */ +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; + +const ENV_WHITELIST = ['HOME', 'USERPROFILE', 'APPDATA', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME']; + +export function expandHome(target, homedir) { + if (target === '~') return homedir; + if (target.startsWith('~/') || target.startsWith('~\\')) { + return homedir + target.slice(1); + } + return target; +} + +export function expandEnvVars(target, env) { + let out = target; + for (const name of ENV_WHITELIST) { + const val = env[name]; + if (val === undefined) continue; + out = out.split(`%${name}%`).join(val); + out = out.split(`\${${name}}`).join(val); + // bare $VAR — only when followed by non-word boundary + out = out.replace(new RegExp(`\\$${name}(?![A-Za-z0-9_])`, 'g'), val); + } + return out; +} + +export function caseFold(p, platform) { + return platform === 'win32' ? p.toLowerCase() : p; +} + +export function globToRegExp(pattern) { + let re = ''; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + re += '.*'; // ** — across separators + i++; + if (pattern[i + 1] === '/') i++; // consume trailing slash of **/ + } else { + re += '[^/]*'; // * — within segment + } + } else if (c === '?') { + re += '[^/]'; + } else if ('.+^${}()|[]\\'.includes(c)) { + re += '\\' + c; + } else { + re += c; + } + } + return new RegExp(`^${re}$`); +} + +export function globMatch(pathStr, pattern) { + const norm = (s) => s.split('\\').join('/'); + return globToRegExp(norm(pattern)).test(norm(pathStr)); +} + +export function pathNormalize(target, opts = {}) { + const { + homedir = os.homedir(), + env = process.env, + platform = process.platform, + realpath = fs.realpathSync, + resolve = path.resolve, + } = opts; + let p = expandHome(target, homedir); + p = expandEnvVars(p, env); + const resolved = resolve(p); + let real; + try { + real = realpath(resolved); + } catch { + real = resolved; // ENOENT — best-effort resolved path + } + return caseFold(real, platform); +} \ No newline at end of file diff --git a/tools/path-normalization.test.mjs b/tools/path-normalization.test.mjs new file mode 100644 index 00000000..c0d9c9c4 --- /dev/null +++ b/tools/path-normalization.test.mjs @@ -0,0 +1,95 @@ +// tools/path-normalization.test.mjs +import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import { + expandHome, expandEnvVars, caseFold, globToRegExp, globMatch, pathNormalize, +} from './path-normalization.mjs'; + +describe('expandHome', () => { + it('replaces leading ~ with homedir', () => { + expect(expandHome('~/.claude/runtime', '/home/u')).toBe('/home/u/.claude/runtime'); + }); + it('replaces bare ~', () => { + expect(expandHome('~', '/home/u')).toBe('/home/u'); + }); + it('does NOT replace ~ in the middle', () => { + expect(expandHome('/a/~/b', '/home/u')).toBe('/a/~/b'); + }); + it('leaves path without ~ unchanged', () => { + expect(expandHome('/abs/path', '/home/u')).toBe('/abs/path'); + }); +}); + +describe('expandEnvVars', () => { + it('%APPDATA%/x → /a/x', () => { + expect(expandEnvVars('%APPDATA%/x', { APPDATA: '/a' })).toBe('/a/x'); + }); + it('${HOME}/y → /h/y', () => { + expect(expandEnvVars('${HOME}/y', { HOME: '/h' })).toBe('/h/y'); + }); + it('$USERPROFILE\\z → C:/u\\z', () => { + expect(expandEnvVars('$USERPROFILE\\z', { USERPROFILE: 'C:/u' })).toBe('C:/u\\z'); + }); + it('%SECRET%/x unchanged (not whitelisted)', () => { + expect(expandEnvVars('%SECRET%/x', { SECRET: '/s' })).toBe('%SECRET%/x'); + }); + it('$HOMEDIR not matched inside longer name (boundary)', () => { + expect(expandEnvVars('$HOMEDIR', { HOME: '/h' })).toBe('$HOMEDIR'); + }); +}); + +describe('caseFold', () => { + it('lowercases on win32', () => { + expect(caseFold('C:/Foo/Bar', 'win32')).toBe('c:/foo/bar'); + }); + it('unchanged on linux', () => { + expect(caseFold('/Foo/Bar', 'linux')).toBe('/Foo/Bar'); + }); +}); + +describe('globToRegExp / globMatch', () => { + it('single * matches within segment', () => { + expect(globMatch('docs/superpowers/plans/x.md', 'docs/superpowers/plans/*.md')).toBe(true); + }); + it('single * does NOT cross segment boundary', () => { + expect(globMatch('docs/superpowers/plans/sub/x.md', 'docs/superpowers/plans/*.md')).toBe(false); + }); + it('** matches across segments', () => { + expect(globMatch('a/b/c/x.test.mjs', '**/*.test.mjs')).toBe(true); + }); + it('** matches wildcard extension', () => { + expect(globMatch('x.test.ts', '**/*.test.*')).toBe(true); + }); + it('does not match unrelated path', () => { + expect(globMatch('app/Models/User.php', 'docs/superpowers/plans/*.md')).toBe(false); + }); + it('tests/** matches nested', () => { + expect(globMatch('tests/Feature/Foo.php', 'tests/**')).toBe(true); + }); + it('backslash normalized before match', () => { + expect(globMatch('C:\\proj\\tests\\a.test.mjs', '**/*.test.mjs')).toBe(true); + }); +}); + +describe('pathNormalize', () => { + it('expands ~ and resolves', () => { + expect(pathNormalize('~/x', { homedir: '/h', realpath: (p) => p, resolve: (p) => p, platform: 'linux', env: {} })).toBe('/h/x'); + }); + it('collapses .. via path.resolve', () => { + // Use path.posix.resolve to simulate POSIX behaviour cross-platform + const result = pathNormalize('a/../b', { realpath: (p) => p, resolve: path.posix.resolve, platform: 'linux', homedir: '/h', env: {} }); + expect(result.endsWith('/b')).toBe(true); + }); + it('falls back to resolved on ENOENT', () => { + expect(pathNormalize('/missing', { + realpath: () => { throw new Error('ENOENT'); }, + resolve: (p) => p, + platform: 'linux', + homedir: '/h', + env: {}, + })).toBe('/missing'); + }); + it('case-folds on win32', () => { + expect(pathNormalize('/A/B', { realpath: (p) => p, resolve: (p) => p, platform: 'win32', homedir: '/h', env: {} })).toBe('/a/b'); + }); +});