fix(router-gate): stream A path-normalization — $& replacement, narrow catch, BOM/EOF, docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// tools/path-normalization.mjs
|
||||
// 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).
|
||||
@@ -25,8 +25,9 @@ export function expandEnvVars(target, env) {
|
||||
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);
|
||||
// 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;
|
||||
}
|
||||
@@ -35,6 +36,7 @@ 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++) {
|
||||
@@ -63,6 +65,17 @@ export function globMatch(pathStr, pattern) {
|
||||
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(),
|
||||
@@ -77,8 +90,9 @@ export function pathNormalize(target, opts = {}) {
|
||||
let real;
|
||||
try {
|
||||
real = realpath(resolved);
|
||||
} catch {
|
||||
real = resolved; // ENOENT — best-effort resolved path
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ describe('expandEnvVars', () => {
|
||||
it('$HOMEDIR not matched inside longer name (boundary)', () => {
|
||||
expect(expandEnvVars('$HOMEDIR', { HOME: '/h' })).toBe('$HOMEDIR');
|
||||
});
|
||||
it('val containing $& is inserted literally (no replacement-pattern misinterpretation)', () => {
|
||||
expect(expandEnvVars('$HOME/test', { HOME: '/a/$&/x' })).toBe('/a/$&/x/test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('caseFold', () => {
|
||||
@@ -80,7 +83,7 @@ describe('pathNormalize', () => {
|
||||
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', () => {
|
||||
it('falls back to resolved on ENOENT (no .code)', () => {
|
||||
expect(pathNormalize('/missing', {
|
||||
realpath: () => { throw new Error('ENOENT'); },
|
||||
resolve: (p) => p,
|
||||
@@ -89,6 +92,26 @@ describe('pathNormalize', () => {
|
||||
env: {},
|
||||
})).toBe('/missing');
|
||||
});
|
||||
it('falls back to resolved when error has code ENOENT', () => {
|
||||
const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
expect(pathNormalize('/missing2', {
|
||||
realpath: () => { throw err; },
|
||||
resolve: (p) => p,
|
||||
platform: 'linux',
|
||||
homedir: '/h',
|
||||
env: {},
|
||||
})).toBe('/missing2');
|
||||
});
|
||||
it('rethrows non-ENOENT errors (e.g. EACCES)', () => {
|
||||
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
expect(() => pathNormalize('/denied', {
|
||||
realpath: () => { throw err; },
|
||||
resolve: (p) => p,
|
||||
platform: 'linux',
|
||||
homedir: '/h',
|
||||
env: {},
|
||||
})).toThrow('permission denied');
|
||||
});
|
||||
it('case-folds on win32', () => {
|
||||
expect(pathNormalize('/A/B', { realpath: (p) => p, resolve: (p) => p, platform: 'win32', homedir: '/h', env: {} })).toBe('/a/b');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user