Files
portal/tools/path-normalization.mjs
T
2026-05-29 19:48:49 +03:00

99 lines
3.4 KiB
JavaScript

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