feat(router-gate): stream A — path-normalization + glob util (§3.1.1)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user