// 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); }