From e9ee3ea116639fc20e19952d9f8871dafdf87599 Mon Sep 17 00:00:00 2001 From: Giancarlo Erra Date: Fri, 22 May 2026 15:59:13 +0100 Subject: [PATCH 1/2] fix(graph): normalize Windows backslash paths to forward slashes On Windows, path.relative() and path.join() return backslash separators. Graph node keys were stored with native separators, but query inputs use forward slashes, causing silent lookup failures on Windows. Add toForwardSlash() utility and apply it at build time (file walker, resolution functions) and query time (getFileDependencies, getSymbolContext, listSymbols) for defense-in-depth. No-op on macOS/Linux where path.relative() already returns forward slashes. Existing Windows symbol graph caches require one rebuild. Fixes #60 --- src/constants.ts | 12 ++++++++++++ src/services/code-graph.ts | 4 ++-- src/services/graph-analysis.ts | 7 +++++-- src/services/graph-impact.ts | 9 ++++++--- src/services/graph-resolution.ts | 29 +++++++++++++--------------- tests/unit/constants.test.ts | 32 +++++++++++++++++++++++++++++++ tests/unit/graph-analysis.test.ts | 32 +++++++++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 23 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 38e9f35..5f84659 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -165,6 +165,18 @@ export const ENTRY_POINT_NAMES: Record> = { php: new Set(["main"]), }; +// ── Path normalization ────────────────────────────────────────────────── + +/** + * Normalize path separators to forward slashes. + * On POSIX this is a no-op; on Windows it replaces every `\` with `/`. + * Used for graph node keys, fileSet entries, and query inputs so that + * lookups succeed regardless of the host OS separator convention. + */ +export function toForwardSlash(p: string): string { + return p.replace(/\\/g, "/"); +} + // ── File type configuration ───────────────────────────────────────────── export const SUPPORTED_EXTENSIONS = new Set([ diff --git a/src/services/code-graph.ts b/src/services/code-graph.ts index f566b56..c0b0854 100644 --- a/src/services/code-graph.ts +++ b/src/services/code-graph.ts @@ -6,7 +6,7 @@ import { createRequire } from "node:module"; import path from "node:path"; import { Lang, registerDynamicLanguage } from "@ast-grep/napi"; import { graphCollectionName, projectIdFromPath } from "../config.js"; -import { EXTRA_EXTENSIONS, getLanguageFromExtension, MAX_GRAPH_FILE_BYTES } from "../constants.js"; +import { EXTRA_EXTENSIONS, getLanguageFromExtension, MAX_GRAPH_FILE_BYTES, toForwardSlash } from "../constants.js"; import type { CodeGraph, CodeGraphEdge, CodeGraphNode, SymbolEdge, SymbolGraphFilePayload, SymbolGraphMeta, SymbolNode, SymbolRef, @@ -596,7 +596,7 @@ async function getGraphableFiles( for (const entry of entries) { const fullPath = path.join(dir, entry.name); - const relPath = path.relative(projectPath, fullPath); + const relPath = toForwardSlash(path.relative(projectPath, fullPath)); if (shouldIgnore(ig, relPath)) continue; diff --git a/src/services/graph-analysis.ts b/src/services/graph-analysis.ts index 62df125..bc4354e 100644 --- a/src/services/graph-analysis.ts +++ b/src/services/graph-analysis.ts @@ -1,17 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import path from "node:path"; -import { getLanguageFromExtension } from "../constants.js"; +import { getLanguageFromExtension, toForwardSlash } from "../constants.js"; import type { CodeGraph } from "../types.js"; /** * Get dependencies for a specific file. + * The input path is normalized to forward slashes so lookups succeed + * regardless of whether the caller passes `/` or `\` separators. */ export function getFileDependencies(graph: CodeGraph, relativePath: string): { imports: string[]; importedBy: string[]; } { - const node = graph.nodes.find((n) => n.relativePath === relativePath); + const normalized = toForwardSlash(relativePath); + const node = graph.nodes.find((n) => n.relativePath === normalized); if (!node) { return { imports: [], importedBy: [] }; } diff --git a/src/services/graph-impact.ts b/src/services/graph-impact.ts index 6d1cd8b..6bb7e5f 100644 --- a/src/services/graph-impact.ts +++ b/src/services/graph-impact.ts @@ -7,7 +7,7 @@ * lazy-loaded per-file payloads. */ -import { MAX_FLOW_DEPTH, MAX_IMPACT_DEPTH } from "../constants.js"; +import { MAX_FLOW_DEPTH, MAX_IMPACT_DEPTH, toForwardSlash } from "../constants.js"; import type { SymbolNode } from "../types.js"; import { type SymbolGraphCache, @@ -184,7 +184,10 @@ export async function getSymbolContext( ): Promise { const nameIndex = await cache.getNameIndex(); let refs = nameIndex.get(name) ?? []; - if (fileHint) refs = refs.filter((r) => r.file === fileHint); + if (fileHint) { + const normalizedHint = toForwardSlash(fileHint); + refs = refs.filter((r) => r.file === normalizedHint); + } if (refs.length === 0) return []; const reverseIndex = await cache.getReverseFileIndex(); @@ -237,7 +240,7 @@ export async function listSymbols( const out: SymbolNode[] = []; if (opts.file) { - const payload = await cache.getFilePayload(opts.file); + const payload = await cache.getFilePayload(toForwardSlash(opts.file)); if (!payload) return []; for (const s of payload.symbols) { if (s.name === "") continue; diff --git a/src/services/graph-resolution.ts b/src/services/graph-resolution.ts index 58a4744..a6f1370 100644 --- a/src/services/graph-resolution.ts +++ b/src/services/graph-resolution.ts @@ -2,6 +2,7 @@ // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import { readFileSync } from "node:fs"; import path from "node:path"; +import { toForwardSlash } from "../constants.js"; import type { PathAliases } from "./graph-aliases.js"; // ── Module resolution ──────────────────────────────────────────────────── @@ -12,7 +13,7 @@ import type { PathAliases } from "./graph-aliases.js"; * For a Maven/Gradle multi-module layout such as: * module-a/sub-module/src/main/java/com/example/Foo.java * the map entry is: - * key: "com/example/Foo.java" (platform-normalised with path.sep) + * key: "com/example/Foo.java" (forward-slash-normalized) * value: "module-a/sub-module/src/main/java/com/example/Foo.java" * * This enables O(1) resolution of fully-qualified class names that cannot be @@ -41,7 +42,7 @@ export function buildJvmSuffixMap(fileSet: Set): Map { if (idx !== -1) { // classPath = everything after src/main/, e.g. com/example/Foo.java - const classPath = parts.slice(idx + 3).join(path.sep); + const classPath = parts.slice(idx + 3).join("/"); // Only register the first match to avoid ambiguity for duplicate class names. if (!map.has(classPath)) { map.set(classPath, f); @@ -184,13 +185,9 @@ export function buildGoModuleInfo( const packageMap = new Map(); for (const f of goFiles) { - // Normalize the directory key to forward slashes. Go import paths - // always use forward slashes regardless of host OS, so the key must - // be in the same form for the lookup in resolveImport to succeed on - // Windows (where path.dirname produces backslash separators for - // nested directories like `pkg\subpkg`). The map value keeps the - // file's native-separator form so it matches fileSet entries used - // elsewhere as graph node keys. + // Go import paths always use forward slashes. fileSet entries are + // also forward-slash-normalized (see toForwardSlash in constants.ts), + // so the key and value are both in the same form. const dir = path.dirname(f).replace(/\\/g, "/"); // "." for files at the project root if (!packageMap.has(dir)) { packageMap.set(dir, f); @@ -351,7 +348,7 @@ export function resolveImport( // The map is built once per graph build (O(n)) and looked up in O(1). if (jvmSuffixMap) { for (const ext of exts) { - const classPath = filePath.replace(/\//g, path.sep) + ext; + const classPath = filePath + ext; const found = jvmSuffixMap.get(classPath); if (found) return found; } @@ -419,7 +416,7 @@ export function resolveImport( path.join(sourceDir, moduleSpecifier, "mod.rs"), ]; for (const candidate of candidates) { - const rel = path.relative(projectPath, candidate); + const rel = toForwardSlash(path.relative(projectPath, candidate)); if (fileSet.has(rel)) return rel; } } @@ -564,7 +561,7 @@ function resolveRelativePath( extensions: string[], ): string | null { const fullPath = path.resolve(baseDir, modulePath); - const relPath = path.relative(projectPath, fullPath); + const relPath = toForwardSlash(path.relative(projectPath, fullPath)); // Direct match if (fileSet.has(relPath)) return relPath; @@ -590,7 +587,7 @@ function resolveRelativePath( // Try as directory with index file for (const ext of extensions) { - const indexFile = path.join(relPath, `index${ext}`); + const indexFile = toForwardSlash(path.join(relPath, `index${ext}`)); if (fileSet.has(indexFile)) return indexFile; } @@ -600,11 +597,11 @@ function resolveRelativePath( const base = path.basename(relPath); if (!base.startsWith("_")) { // Try _name (direct) - const partial = path.join(dir, `_${base}`); + const partial = toForwardSlash(path.join(dir, `_${base}`)); if (fileSet.has(partial)) return partial; // Try _name with extensions for (const ext of extensions) { - const partialExt = path.join(dir, `_${base}${ext}`); + const partialExt = toForwardSlash(path.join(dir, `_${base}${ext}`)); if (fileSet.has(partialExt)) return partialExt; } } @@ -612,7 +609,7 @@ function resolveRelativePath( // Python: try __init__.py if (extensions.includes(".py")) { - const initFile = path.join(relPath, "__init__.py"); + const initFile = toForwardSlash(path.join(relPath, "__init__.py")); if (fileSet.has(initFile)) return initFile; } diff --git a/tests/unit/constants.test.ts b/tests/unit/constants.test.ts index 740a210..8656eb7 100644 --- a/tests/unit/constants.test.ts +++ b/tests/unit/constants.test.ts @@ -27,6 +27,7 @@ import { SEARCH_MIN_SCORE, SPECIAL_FILES, SUPPORTED_EXTENSIONS, + toForwardSlash, } from "../../src/constants.js"; describe("constants", () => { @@ -373,6 +374,37 @@ describe("constants", () => { }); }); +describe("toForwardSlash", () => { + it("returns the same string when no backslashes are present", () => { + expect(toForwardSlash("src/index.ts")).toBe("src/index.ts"); + }); + + it("replaces single backslash with forward slash", () => { + expect(toForwardSlash("src\\index.ts")).toBe("src/index.ts"); + }); + + it("replaces multiple backslashes in a path", () => { + expect(toForwardSlash("src\\services\\graph-analysis.ts")).toBe("src/services/graph-analysis.ts"); + }); + + it("handles deeply nested Windows paths", () => { + expect(toForwardSlash("src\\a\\b\\c\\d\\file.ts")).toBe("src/a/b/c/d/file.ts"); + }); + + it("handles empty string", () => { + expect(toForwardSlash("")).toBe(""); + }); + + it("handles path with mixed separators", () => { + expect(toForwardSlash("src/services\\graph-analysis.ts")).toBe("src/services/graph-analysis.ts"); + }); + + it("is a no-op on POSIX-style paths", () => { + const posixPath = "src/services/code-graph.ts"; + expect(toForwardSlash(posixPath)).toBe(posixPath); + }); +}); + describe("resolveQdrantPort", () => { it("returns explicit port from URL", () => { expect(resolveQdrantPort("https://qdrant.example.com:6333")).toBe(6333); diff --git a/tests/unit/graph-analysis.test.ts b/tests/unit/graph-analysis.test.ts index ca88ef7..8757da4 100644 --- a/tests/unit/graph-analysis.test.ts +++ b/tests/unit/graph-analysis.test.ts @@ -113,6 +113,38 @@ describe("graph-analysis", () => { expect(deps.imports).toHaveLength(0); expect(deps.importedBy).toHaveLength(0); }); + + it("normalizes Windows backslash paths to match forward-slash graph keys", () => { + const graph = createSampleGraph(); + // Graph keys use forward slashes; simulate a Windows-style query + const deps = getFileDependencies(graph, "src\\index.ts"); + + expect(deps.imports).toContain("src/utils.ts"); + expect(deps.imports).toContain("src/types.ts"); + }); + + it("normalizes deeply nested Windows paths", () => { + const nodes: CodeGraphNode[] = [ + makeNode("src/services/graph/analysis.ts", ["src/types.ts"], []), + makeNode("src/types.ts", [], ["src/services/graph/analysis.ts"]), + ]; + const edges: CodeGraphEdge[] = [ + makeEdge("src/services/graph/analysis.ts", "src/types.ts"), + ]; + const graph = makeGraph(nodes, edges); + + const deps = getFileDependencies(graph, "src\\services\\graph\\analysis.ts"); + expect(deps.imports).toContain("src/types.ts"); + }); + + it("handles mixed separator paths", () => { + const graph = createSampleGraph(); + const deps = getFileDependencies(graph, "src/utils.ts"); + const depsMixed = getFileDependencies(graph, "src\\utils.ts"); + + expect(depsMixed.imports).toEqual(deps.imports); + expect(depsMixed.importedBy).toEqual(deps.importedBy); + }); }); describe("findCircularDependencies", () => { From 4526ea58ae332b3839019eb3f46a0eff08801bd6 Mon Sep 17 00:00:00 2001 From: Giancarlo Erra Date: Fri, 22 May 2026 16:07:14 +0100 Subject: [PATCH 2/2] fix(graph): normalize stored node keys during lookup for legacy cache compat Address CodeRabbit review: also normalize stored graph node keys when comparing, not just the query input. Handles pre-fix Windows caches where node keys still contain backslashes until the graph is rebuilt. --- src/services/graph-analysis.ts | 2 +- tests/unit/graph-analysis.test.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/services/graph-analysis.ts b/src/services/graph-analysis.ts index bc4354e..1650092 100644 --- a/src/services/graph-analysis.ts +++ b/src/services/graph-analysis.ts @@ -14,7 +14,7 @@ export function getFileDependencies(graph: CodeGraph, relativePath: string): { importedBy: string[]; } { const normalized = toForwardSlash(relativePath); - const node = graph.nodes.find((n) => n.relativePath === normalized); + const node = graph.nodes.find((n) => toForwardSlash(n.relativePath) === normalized); if (!node) { return { imports: [], importedBy: [] }; } diff --git a/tests/unit/graph-analysis.test.ts b/tests/unit/graph-analysis.test.ts index 8757da4..89f7034 100644 --- a/tests/unit/graph-analysis.test.ts +++ b/tests/unit/graph-analysis.test.ts @@ -145,6 +145,26 @@ describe("graph-analysis", () => { expect(depsMixed.imports).toEqual(deps.imports); expect(depsMixed.importedBy).toEqual(deps.importedBy); }); + + it("finds nodes in a legacy cached graph with backslash keys", () => { + // Simulate a graph built on Windows before the fix: stored keys have backslashes + const nodes: CodeGraphNode[] = [ + makeNode("src\\services\\api.ts", ["src\\types.ts"], []), + makeNode("src\\types.ts", [], ["src\\services\\api.ts"]), + ]; + // Fix relativePath (makeNode sets it from the argument) + nodes[0].relativePath = "src\\services\\api.ts"; + nodes[1].relativePath = "src\\types.ts"; + + const edges: CodeGraphEdge[] = [ + makeEdge("src\\services\\api.ts", "src\\types.ts"), + ]; + const graph = makeGraph(nodes, edges); + + // Query with forward slashes should still find the node + const deps = getFileDependencies(graph, "src/services/api.ts"); + expect(deps.imports).toContain("src\\types.ts"); + }); }); describe("findCircularDependencies", () => {