Files
brain/tools/path-normalization.test.mjs

189 lines
7.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tools/path-normalization.test.mjs
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import {
expandHome, expandEnvVars, caseFold, globToRegExp, globMatch, pathNormalize, pathNormalizeSafe,
} from './path-normalization.mjs';
describe('pathNormalizeSafe тотальна (M7 Фаза 0, правило 7а, SE-I/L6)', () => {
it('не бросает когда realpath кидает не-ENOENT (EACCES) → ""', () => {
const boom = () => { const e = new Error('denied'); e.code = 'EACCES'; throw e; };
const opts = { realpath: boom, homedir: '/h', platform: 'linux', resolve: (p) => p };
expect(() => pathNormalizeSafe('/x', opts)).not.toThrow();
expect(pathNormalizeSafe('/x', opts)).toBe('');
});
it('успех → тот же результат, что строгий pathNormalize', () => {
const id = (p) => p;
const opts = { realpath: id, homedir: '/h', platform: 'linux', resolve: id };
expect(pathNormalizeSafe('/a/b', opts)).toBe('/a/b');
});
});
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');
});
it('val containing $& is inserted literally (no replacement-pattern misinterpretation)', () => {
expect(expandEnvVars('$HOME/test', { HOME: '/a/$&/x' })).toBe('/a/$&/x/test');
});
});
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');
});
// Smoke 5 integration bug 2026-05-30 — Stream A pathNormalize returned backslashes
// on win32, breaking DEFAULT_PROTECTED_PATTERNS regex (forward-slash-only) match.
// Fix: normalize all separators to forward slashes in output, regardless of platform.
it('normalizes backslashes to forward slashes on win32 (integration fix for protected patterns)', () => {
const result = pathNormalize('~/foo/bar.jsonl', {
homedir: 'C:\\Users\\Admin',
realpath: (p) => p,
resolve: path.win32.resolve,
platform: 'win32',
env: {},
});
expect(result).not.toMatch(/\\/);
expect(result.includes('/')).toBe(true);
});
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 (no .code)', () => {
expect(pathNormalize('/missing', {
realpath: () => { throw new Error('ENOENT'); },
resolve: (p) => p,
platform: 'linux',
homedir: '/h',
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');
});
// Stream H Task 9 cosmetic: /c/Users/... (Cygwin/git-bash) → c:/Users/...
// Without this, win32 path.resolve('/c/Users/x') prepends cwd's drive letter
// → c:/c/users/... (doubled drive). Detected during Smoke 5 Real Fix Re-test
// 2026-05-30 (step 4 path display).
it('normalizes /c/Users (cygwin/git-bash drive prefix) to c:/Users before resolve (win32)', () => {
const r = pathNormalize('/c/Users/Admin/.claude/projects/x.jsonl', {
homedir: 'C:\\Users\\Admin',
realpath: (p) => p,
resolve: path.win32.resolve,
platform: 'win32',
env: {},
});
// No double c:/c/, has single c:/users
expect(r).not.toMatch(/c:\/c\//);
expect(r).toMatch(/c:\/users\/admin/);
});
});
describe('expandEnvVars — PowerShell $env:VAR (Stream H Task 9 cosmetic)', () => {
it('expands $env:USERPROFILE to actual home path', () => {
expect(expandEnvVars('$env:USERPROFILE/.claude/projects/x.jsonl', { USERPROFILE: 'C:/Users/Admin' }))
.toBe('C:/Users/Admin/.claude/projects/x.jsonl');
});
it('expands $env:HOME (whitelisted) too', () => {
expect(expandEnvVars('$env:HOME/x', { HOME: '/h' })).toBe('/h/x');
});
it('does not expand non-whitelisted $env:SECRET', () => {
expect(expandEnvVars('$env:SECRET/x', { SECRET: '/s' })).toBe('$env:SECRET/x');
});
});
// P10-b (router-mentor Машина 1): NFC-нормализация выхода — декомпозированный
// Unicode (NFD) не должен обходить regex-защиты, сверяющиеся с композированной формой.
describe('pathNormalize NFC (P10-b)', () => {
it('NFC-normalizes output so decomposed input equals composed', () => {
const opts = { platform: 'linux', homedir: '/home/u', realpath: (p) => p, resolve: (p) => p, env: {} };
const composed = '/tmp/café/x'; // é precomposed (NFC)
const decomposed = '/tmp/café/x'; // e + combining acute (NFD)
expect(composed).not.toBe(decomposed); // guard: genuinely different bytes
expect(pathNormalize(decomposed, opts)).toBe(pathNormalize(composed, opts));
});
});