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..1650092 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) => toForwardSlash(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..89f7034 100644 --- a/tests/unit/graph-analysis.test.ts +++ b/tests/unit/graph-analysis.test.ts @@ -113,6 +113,58 @@ 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); + }); + + 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", () => {