Skip to content
Draft
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
16 changes: 15 additions & 1 deletion bench/suite.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { bench, group, summary } from "mitata";
import { searchProject } from "@claudiu-ceia/sgrep";
import { patchProject } from "@claudiu-ceia/spatch";
import { rankCode } from "../packages/nav/src/code-rank/rank.ts";
import { findTemplateMatches } from "../packages/astkit-core/src/pattern/match.ts";
import { compileTemplate } from "../packages/astkit-core/src/pattern/syntax.ts";
import { createTsFixture } from "./suites/fixtures.ts";
import { createCodeRankFixture, createTsFixture } from "./suites/fixtures.ts";

export function defineBenches(): void {
summary(() => {
Expand All @@ -16,6 +17,19 @@ export function defineBenches(): void {
});
});

group("nav/code-rank", () => {
bench("code-rank: 50 files, 4 exports each", async function* () {
const fixture = await createCodeRankFixture({ fileCount: 50, exportsPerFile: 4 });
try {
yield async () => {
await rankCode({ cwd: fixture.root, scope: "." });
};
} finally {
await fixture.dispose();
}
});
});

group("sgrep", () => {
const pattern = "const :[name] = :[value];";

Expand Down
56 changes: 56 additions & 0 deletions bench/suites/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,59 @@ export async function createTsFixture(options: {
},
};
}

/**
* Creates a fixture workspace suitable for code-rank benchmarks.
* Generates a hub-and-spoke graph: one shared module exports `fileCount` symbols,
* and each spoke file imports and calls a subset of those symbols, producing a
* realistic pattern of cross-file references for the ranking algorithm to process.
*/
export async function createCodeRankFixture(options: {
fileCount: number;
exportsPerFile: number;
}): Promise<Fixture> {
const root = await mkdtemp(path.join(tmpdir(), "astkit-bench-rank-"));
const { fileCount, exportsPerFile } = options;

await writeFile(
path.join(root, "tsconfig.json"),
JSON.stringify(
{
compilerOptions: {
module: "ESNext",
moduleResolution: "Bundler",
target: "ESNext",
strict: true,
},
include: ["**/*.ts"],
},
null,
2,
),
"utf8",
);

// hub.ts: exports one function per file slot so each has a distinct rank.
const hubLines: string[] = [];
for (let fileIndex = 0; fileIndex < fileCount; fileIndex += 1) {
for (let exportIndex = 0; exportIndex < exportsPerFile; exportIndex += 1) {
hubLines.push(`export function sym_${fileIndex}_${exportIndex}(): number { return ${fileIndex * exportsPerFile + exportIndex}; }`);
}
}
await writeFile(path.join(root, "hub.ts"), hubLines.join("\n") + "\n", "utf8");

// spoke_N.ts: imports and calls symbols from hub.ts.
for (let fileIndex = 0; fileIndex < fileCount; fileIndex += 1) {
const imports = Array.from({ length: exportsPerFile }, (_, j) => `sym_${fileIndex}_${j}`).join(", ");
const calls = Array.from({ length: exportsPerFile }, (_, j) => `sym_${fileIndex}_${j}();`).join("\n");
const content = `import { ${imports} } from "./hub.ts";\n\n${calls}\n`;
await writeFile(path.join(root, `spoke-${fileIndex}.ts`), content, "utf8");
}

return {
root,
dispose: async () => {
await rm(root, { recursive: true, force: true });
},
};
}
84 changes: 83 additions & 1 deletion packages/nav/__tests__/code-rank.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { rankCode } from "../src/code-rank/rank.ts";
Expand Down Expand Up @@ -91,6 +91,88 @@ test("rankCode returns empty output when scope has no rankable files", async ()
}
});

test("rankCode ignores references from symlinked files that resolve outside the git boundary", async () => {
const root = await mkdtemp(path.join(tmpdir(), "code-rank-symlink-"));
const workspace = path.join(root, "workspace");
const outsideDir = path.join(root, "outside");

try {
await mkdir(workspace, { recursive: true });
await mkdir(path.join(workspace, ".git"));
await mkdir(outsideDir, { recursive: true });

await writeFile(
path.join(workspace, "tsconfig.json"),
JSON.stringify(
{
compilerOptions: {
module: "ESNext",
moduleResolution: "Bundler",
target: "ESNext",
strict: true,
},
include: ["**/*.ts"],
},
null,
2,
),
"utf8",
);

// a.ts: declares `hot`
await writeFile(
path.join(workspace, "a.ts"),
"export function hot(): number { return 1; }\n",
"utf8",
);

// b.ts: one external reference to `hot` (within boundary)
await writeFile(
path.join(workspace, "b.ts"),
['import { hot } from "./a.ts";', "", "hot();", ""].join("\n"),
"utf8",
);

// outside/c.ts: would add an extra external reference, but lives outside the boundary.
// We create a symlink escape.ts -> outside/c.ts so the TS compiler might pick it up,
// but the canonical path resolves outside the git root.
await writeFile(
path.join(outsideDir, "c.ts"),
['import { hot } from "../workspace/a.ts";', "", "hot();", ""].join("\n"),
"utf8",
);
await symlink(path.join(outsideDir, "c.ts"), path.join(workspace, "escape.ts"));

const result = await rankCode({ cwd: workspace, scope: "." });

const hot = result.symbols.find((s) => s.symbol === "hot");
expect(hot).toBeDefined();
// References from escape.ts (which resolves to outside the git boundary) must not
// appear in referencingFiles; only b.ts (within the boundary) should be listed.
expect(hot!.referencingFiles).toEqual(["b.ts"]);
expect(hot!.referencingFiles.some((f) => f.includes("escape") || f.includes("outside"))).toBe(
false,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("rankCode canonical-path deduplication: path resolved via different spellings counts once", async () => {
const workspace = await createRankFixtureWorkspace();

try {
const result = await rankCode({ cwd: workspace, scope: "." });

// Verify each symbol appears exactly once in the output regardless of how many
// times the same canonical path is presented to the boundary checker.
const names = result.symbols.map((s) => s.symbol);
expect(names).toEqual([...new Set(names)]);
} finally {
await rm(workspace, { recursive: true, force: true });
}
});

async function createRankFixtureWorkspace(): Promise<string> {
const workspace = await mkdtemp(path.join(tmpdir(), "code-rank-"));
await writeFile(
Expand Down
60 changes: 58 additions & 2 deletions packages/nav/__tests__/service.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createService, toPosition, fromPosition, relativePath } from "../src/service.ts";
import {
createCachedBoundaryChecker,
createService,
createWorkspaceBoundary,
fromPosition,
relativePath,
toPosition,
} from "../src/service.ts";

const fixturesDir = path.resolve(import.meta.dir, "fixtures");
let originalCwd: string;
Expand Down Expand Up @@ -149,3 +156,52 @@ test("createService reports semantic tsconfig diagnostics", async () => {
await rm(workspace, { recursive: true, force: true });
}
});

test("createCachedBoundaryChecker returns true for paths within the boundary", async () => {
const root = await mkdtemp(path.join(tmpdir(), "nav-checker-"));
try {
const checker = createCachedBoundaryChecker(createWorkspaceBoundary(root));
const inside = path.join(root, "src", "index.ts");
expect(checker(inside)).toBe(true);
// Second call to the same path exercises the cache branch.
expect(checker(inside)).toBe(true);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("createCachedBoundaryChecker returns false for paths outside the boundary", async () => {
const root = await mkdtemp(path.join(tmpdir(), "nav-checker-"));
try {
const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true });
const checker = createCachedBoundaryChecker(createWorkspaceBoundary(workspace));
const outside = path.join(root, "sibling", "index.ts");
expect(checker(outside)).toBe(false);
expect(checker(outside)).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("createCachedBoundaryChecker treats a symlink pointing outside the boundary as outside", async () => {
const root = await mkdtemp(path.join(tmpdir(), "nav-checker-symlink-"));
try {
const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true });
await mkdir(path.join(workspace, ".git"));

const outsideFile = path.join(root, "outside.ts");
await writeFile(outsideFile, "export const x = 1;\n", "utf8");

const escapeLink = path.join(workspace, "escape.ts");
await symlink(outsideFile, escapeLink);

const checker = createCachedBoundaryChecker(createWorkspaceBoundary(workspace));
expect(checker(escapeLink)).toBe(false);
// Second call must agree with the first (cache must not alter the result).
expect(checker(escapeLink)).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
9 changes: 5 additions & 4 deletions packages/nav/src/code-rank/rank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import {
} from "@claudiu-ceia/astkit-core";
import {
assertPathWithinWorkspaceBoundary,
createCachedBoundaryChecker,
createService,
createWorkspaceBoundary,
fromPosition,
isPathWithinWorkspaceBoundary,
relativePath,
} from "../service.ts";

Expand Down Expand Up @@ -74,6 +74,7 @@ export async function rankCode(options: CodeRankOptions = {}): Promise<CodeRankR
const checker = program.getTypeChecker();
const symbols: RankedSymbol[] = [];
const seenDeclarations = new Set<string>();
const isWithinBoundary = createCachedBoundaryChecker(boundary);

for (const filePath of files) {
const sourceFile = program.getSourceFile(filePath);
Expand Down Expand Up @@ -107,7 +108,7 @@ export async function rankCode(options: CodeRankOptions = {}): Promise<CodeRankR
service.findReferences(declarationSourceFile.fileName, declarationStart),
declarationSourceFile.fileName,
projectRoot,
boundary,
isWithinBoundary,
);
const pos = fromPosition(declarationSourceFile, declarationStart);
symbols.push({
Expand Down Expand Up @@ -152,7 +153,7 @@ function collectReferenceStats(
references: readonly ts.ReferencedSymbol[] | undefined,
declarationFile: string,
projectRoot: string,
boundary: ReturnType<typeof createWorkspaceBoundary>,
isWithinBoundary: (filePath: string) => boolean,
): ReferenceStats {
if (!references || references.length === 0) {
return {
Expand All @@ -177,7 +178,7 @@ function collectReferenceStats(
continue;
}
seenReferences.add(key);
if (!isPathWithinWorkspaceBoundary(boundary, reference.fileName)) {
if (!isWithinBoundary(reference.fileName)) {
continue;
}

Expand Down
28 changes: 27 additions & 1 deletion packages/nav/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ export function createService(

// Ensure requested target files are in the language-service file set.
const targetFiles = normalizeTargetFiles(targetFile);
const fileNameSet = new Set(fileNames);
for (const requestedFile of targetFiles) {
const resolved = path.resolve(cwd, requestedFile);
assertPathWithinWorkspaceBoundary(boundary, resolved, "File path");
if (!fileNames.includes(resolved)) {
if (!fileNameSet.has(resolved)) {
fileNameSet.add(resolved);
fileNames.push(resolved);
}
}
Expand Down Expand Up @@ -186,6 +188,30 @@ export function isPathWithinWorkspaceBoundary(
return isPathWithinBase(boundary.canonicalBoundary, canonicalTarget);
}

/**
* Returns a boundary-membership checker that caches canonical-path resolution
* so each distinct raw path is resolved at most once per checker instance.
* Intended to be created once per operation (e.g. a single `rankCode` run)
* and discarded afterwards; do not share across operations.
*/
export function createCachedBoundaryChecker(
boundaryOrCwd: WorkspaceBoundary | string,
): (filePath: string) => boolean {
const boundary =
typeof boundaryOrCwd === "string" ? createWorkspaceBoundary(boundaryOrCwd) : boundaryOrCwd;
const cache = new Map<string, boolean>();
return function isWithinBoundary(filePath: string): boolean {
const resolved = path.resolve(filePath);
const cached = cache.get(resolved);
if (cached !== undefined) {
return cached;
}
const result = isPathWithinBase(boundary.canonicalBoundary, resolveCanonicalPath(resolved));
cache.set(resolved, result);
return result;
};
}

function findNearestGitRepoRoot(startDirectory: string): string | null {
let current = path.resolve(startDirectory);

Expand Down
Loading