mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-06-02 06:23:43 +02:00
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
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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<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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user