diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index f7733ef19..1139f7864 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -36,6 +36,7 @@ const PLATFORM_LOGIC = [ 'test/unit/lbug-pool-fts-load.test.ts', 'test/unit/repo-manager.test.ts', 'test/unit/repo-manager-finalize-invariant.test.ts', + 'test/unit/git-utils.test.ts', 'test/unit/hooks.test.ts', 'test/unit/hook-db-lock-probe.test.ts', 'test/unit/cursor-hook.test.ts', diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index c94accad7..79c005e92 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -253,6 +253,10 @@ function contractIdFor(method: string, pathNorm: string): string { return `http::${method.toUpperCase()}::${pathNorm}`; } +export function normalizeRepoRelPath(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/^\.\//, ''); +} + // ─── Graph row helpers ─────────────────────────────────────────────── function methodFromRouteReason(reason: string): string | null { @@ -764,20 +768,21 @@ export class HttpRouteExtractor implements ContractExtractor { const out: ExtractedContract[] = []; for (const rel of files) { const detections = await getDetections(rel); + const filePath = normalizeRepoRelPath(rel); for (const d of detections) { if (d.role !== 'provider') continue; const pathNorm = normalizeHttpPath(d.path); // Resolve the handler to a real symbol (named handler, or the inline // arrow that encloses the registration line) so the contract carries a // real symbolUid; fall back to the file + detection name otherwise. - const resolved = await resolveSymbol(rel, d); + const resolved = await resolveSymbol(filePath, d); out.push({ contractId: contractIdFor(d.method, pathNorm), type: 'http', role: 'provider', symbolUid: resolved?.uid ?? '', symbolRef: { - filePath: resolved?.filePath || rel, + filePath: resolved?.filePath || filePath, name: resolved?.name ?? d.name ?? 'handler', }, symbolName: resolved?.name ?? d.name ?? 'handler', @@ -883,19 +888,20 @@ export class HttpRouteExtractor implements ContractExtractor { const out: ExtractedContract[] = []; for (const rel of files) { const detections = await getDetections(rel); + const filePath = normalizeRepoRelPath(rel); for (const d of detections) { if (d.role !== 'consumer') continue; const pathNorm = normalizeConsumerPath(d.path); // Resolve the function CONTAINING the fetch/axios call so the consumer // contract carries a real symbolUid (was always '' — the gap that left // cross-repo trace/impact unable to traverse HTTP links). - const resolved = await resolveSymbol(rel, d); + const resolved = await resolveSymbol(filePath, d); out.push({ contractId: contractIdFor(d.method, pathNorm), type: 'http', role: 'consumer', symbolUid: resolved?.uid ?? '', - symbolRef: { filePath: resolved?.filePath || rel, name: resolved?.name ?? 'fetch' }, + symbolRef: { filePath: resolved?.filePath || filePath, name: resolved?.name ?? 'fetch' }, symbolName: resolved?.name ?? 'fetch', confidence: d.confidence, meta: { diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index e539d30c9..aacbb74cb 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -103,6 +103,12 @@ export const getRemoteUrl = (repoPath: string): string | undefined => { * Find the git repository root from any path inside the repo */ export const getGitRoot = (fromPath: string): string | null => { + const resolved = path.resolve(fromPath); + // Avoid git rev-parse --show-toplevel trimming trailing spaces from the + // repository root on Windows; callers that need identity keys canonicalize + // this value with realpath before comparing it. + if (hasGitDir(resolved)) return resolved; + try { const raw = chompGitOutput( execSync('git rev-parse --show-toplevel', { diff --git a/gitnexus/test/integration/http-inline-handler-symbol-roundtrip.test.ts b/gitnexus/test/integration/http-inline-handler-symbol-roundtrip.test.ts index 5332bd7a4..b009f93b0 100644 --- a/gitnexus/test/integration/http-inline-handler-symbol-roundtrip.test.ts +++ b/gitnexus/test/integration/http-inline-handler-symbol-roundtrip.test.ts @@ -62,7 +62,7 @@ func main() { const result = await runPipelineFromRepo(repoDir, () => {}, {}); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await adapter.initLbug(dbPath); - await adapter.loadGraphToLbug(result.graph, tmpBase, storagePath); + await adapter.loadGraphToLbug(result.graph, repoDir, storagePath); }, 120_000); afterAll(async () => { diff --git a/gitnexus/test/integration/literal-collectors.test.ts b/gitnexus/test/integration/literal-collectors.test.ts index 064bc0445..b98ad17e5 100644 --- a/gitnexus/test/integration/literal-collectors.test.ts +++ b/gitnexus/test/integration/literal-collectors.test.ts @@ -65,7 +65,7 @@ describe('literal-collectors', () => { (f) => f.field === 'pattern' && f.receiverNodeType === 'is_pattern_expression' && - f.file.endsWith('type-extractors/csharp.ts'), + f.file.replace(/\\/g, '/').endsWith('type-extractors/csharp.ts'), ); expect(scoped).toBeDefined(); // a childForFieldName NOT inside a single positive type-guard stays unscoped diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 8fc8dd3ef..1853f12a0 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -1317,6 +1317,13 @@ describe('LocalBackend.callTool', () => { }, ]) .mockResolvedValue([]); + const repoDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-rename-')); + (listRegisteredRepos as any).mockResolvedValue([ + { ...MOCK_REPO_ENTRY, path: repoDir, storagePath: path.join(repoDir, '.gitnexus') }, + ]); + backend = new LocalBackend(); + await backend.init(); + const readSpy = vi .spyOn(fsPromises, 'readFile') .mockResolvedValue('function oldName() {}\n' as unknown as Buffer); @@ -1337,6 +1344,7 @@ describe('LocalBackend.callTool', () => { } finally { readSpy.mockRestore(); writeSpy.mockRestore(); + rmSync(repoDir, { recursive: true, force: true }); } }); diff --git a/gitnexus/test/unit/git-utils.test.ts b/gitnexus/test/unit/git-utils.test.ts index 14a51a385..d04524d5c 100644 --- a/gitnexus/test/unit/git-utils.test.ts +++ b/gitnexus/test/unit/git-utils.test.ts @@ -8,7 +8,30 @@ import { describe, it, expect, vi } from 'vitest'; import path from 'path'; import os from 'os'; import fs from 'fs'; -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; + +const gitExecutable = (() => { + if (process.platform !== 'win32') return 'git'; + try { + return ( + execFileSync('where.exe', ['git'], { encoding: 'utf8' }).split(/\r?\n/).find(Boolean) ?? 'git' + ); + } catch { + return 'git'; + } +})(); + +const isolatedTmpRoot = (() => { + const root = + process.platform === 'win32' + ? path.join(path.parse(os.tmpdir()).root, 'gitnexus-outside-git') + : path.join(os.tmpdir(), 'gitnexus-outside-git'); + fs.mkdirSync(root, { recursive: true }); + return root; +})(); + +const makeIsolatedTempDir = (prefix = 'gitnexus-test-'): string => + fs.mkdtempSync(path.join(isolatedTmpRoot, prefix)); // ─── hasGitDir ──────────────────────────────────────────────────────────── // @@ -71,7 +94,7 @@ describe('hasGitDir', () => { describe('isGitRepo', () => { it('returns false for a plain (non-git) directory', async () => { const { isGitRepo } = await import('../../src/storage/git.js'); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-')); + const tmpDir = makeIsolatedTempDir(); try { expect(isGitRepo(tmpDir)).toBe(false); } finally { @@ -124,7 +147,7 @@ describe('getCurrentCommit', () => { describe('getGitRoot', () => { it('returns null for a plain temp directory', async () => { const { getGitRoot } = await import('../../src/storage/git.js'); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-')); + const tmpDir = makeIsolatedTempDir(); try { expect(getGitRoot(tmpDir)).toBeNull(); } finally { @@ -150,10 +173,12 @@ describe('getGitRoot', () => { it('preserves a trailing-space repository directory name (#2190)', async () => { const { getGitRoot } = await import('../../src/storage/git.js'); const parentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-space-root-')); + const initDir = path.join(parentDir, 'repo-init'); const repoDir = path.join(parentDir, 'repo '); try { - fs.mkdirSync(repoDir); - execSync('git init -q', { cwd: repoDir }); + fs.mkdirSync(initDir); + execFileSync(gitExecutable, ['init', '-q'], { cwd: initDir, stdio: 'ignore' }); + fs.renameSync(initDir, repoDir); expect(getGitRoot(repoDir)).toBe(path.resolve(repoDir)); } finally { @@ -241,7 +266,7 @@ describe('getRemoteUrl', () => { describe('getCanonicalRepoRoot', () => { it('returns null for a plain temp directory (not a git repo)', async () => { const { getCanonicalRepoRoot } = await import('../../src/storage/git.js'); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-canonical-')); + const tmpDir = makeIsolatedTempDir('gitnexus-canonical-'); try { expect(getCanonicalRepoRoot(tmpDir)).toBeNull(); } finally { @@ -276,7 +301,7 @@ describe('getCanonicalRepoRoot', () => { const { getCanonicalRepoRoot, getGitRoot } = await import('../../src/storage/git.js'); const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-canonical-wt-')); try { - execSync('git init -q', { cwd: repoDir }); + execFileSync(gitExecutable, ['init', '-q'], { cwd: repoDir, stdio: 'ignore' }); // `git worktree add` requires at least one commit on a real branch. execSync('git config user.email "test@example.com"', { cwd: repoDir }); execSync('git config user.name "Test"', { cwd: repoDir }); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index e38e5cf16..9dda77ffa 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -10,7 +10,10 @@ vi.mock('../../../src/core/tree-sitter/safe-parse.js', async () => { return buildSafeParseMock(parseSourceSafeSpy); }); -import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js'; +import { + HttpRouteExtractor, + normalizeRepoRelPath, +} from '../../../src/core/group/extractors/http-route-extractor.js'; import { getPluginForFile } from '../../../src/core/group/extractors/http-patterns/index.js'; import type { RepoHandle } from '../../../src/core/group/types.js'; @@ -43,6 +46,14 @@ describe('HttpRouteExtractor', () => { const toPosixPath = (filePath: string): string => filePath.replace(/\\/g, '/'); + describe('repo-relative path normalization', () => { + it('normalizes Windows source-scan paths before symbol lookup', () => { + expect(normalizeRepoRelPath('src\\api\\users.ts')).toBe('src/api/users.ts'); + expect(normalizeRepoRelPath('.\\src\\api\\users.ts')).toBe('src/api/users.ts'); + expect(normalizeRepoRelPath('./src/api/users.ts')).toBe('src/api/users.ts'); + }); + }); + describe('symbolUid resolution via containment', () => { it('resolves a source-scan consumer to the function CONTAINING the fetch', async () => { const dir = path.join(tmpDir, 'consumer-containment');