// 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. // Use a function replacer so `val` is inserted literally (avoids $& / $' / $` replacement-pattern misinterpretation). 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; } // NOTE: `pattern` must use forward slashes. For cross-platform path matching use `globMatch` instead. 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)); } /** * Normalize a path: expand ~, expand whitelisted env vars, resolve, realpath, case-fold. * * @param {string} target - Raw path (may contain ~ or $VAR). * @param {object} [opts] * @param {string} [opts.homedir] - Override home directory (default: os.homedir()). * @param {object} [opts.env] - Override environment map (default: process.env). * @param {string} [opts.platform] - Override platform string (default: process.platform). * @param {Function} [opts.realpath] - Injectable realpath (default: fs.realpathSync) — used for test determinism. * @param {Function} [opts.resolve] - Injectable path.resolve (default: path.resolve) — injectable for cross-platform test determinism. */ 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 (e) { if (e && e.code && e.code !== 'ENOENT') throw e; // surface real FS errors; fail-close handled by caller real = resolved; // ENOENT — best-effort resolved path for unknown-state files } return caseFold(real, platform); }