Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,18 @@ export const ENTRY_POINT_NAMES: Record<string, Set<string>> = {
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([
Expand Down
4 changes: 2 additions & 2 deletions src/services/code-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down
7 changes: 5 additions & 2 deletions src/services/graph-analysis.ts
Original file line number Diff line number Diff line change
@@ -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: [] };
}
Expand Down
9 changes: 6 additions & 3 deletions src/services/graph-impact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -184,7 +184,10 @@ export async function getSymbolContext(
): Promise<SymbolContext[]> {
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();
Expand Down Expand Up @@ -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 === "<module>") continue;
Expand Down
29 changes: 13 additions & 16 deletions src/services/graph-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────
Expand All @@ -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
Expand Down Expand Up @@ -41,7 +42,7 @@ export function buildJvmSuffixMap(fileSet: Set<string>): Map<string, string> {

if (idx !== -1) {
// classPath = everything after src/main/<lang>, 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);
Expand Down Expand Up @@ -184,13 +185,9 @@ export function buildGoModuleInfo(

const packageMap = new Map<string, string>();
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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -600,19 +597,19 @@ 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;
}
}
}

// 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;
}

Expand Down
32 changes: 32 additions & 0 deletions tests/unit/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
SEARCH_MIN_SCORE,
SPECIAL_FILES,
SUPPORTED_EXTENSIONS,
toForwardSlash,
} from "../../src/constants.js";

describe("constants", () => {
Expand Down Expand Up @@ -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);
Expand Down
52 changes: 52 additions & 0 deletions tests/unit/graph-analysis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading