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
118 changes: 118 additions & 0 deletions packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,124 @@ describe("createIndexer", () => {
await indexer.dispose();
}
});

it("resolvePreciseEdges upgrades a heuristic CALLS edge to precise via a location resolver", async () => {
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "dextree-core-precise-"));
tempDirs.push(workspaceRoot);
await mkdir(resolve(workspaceRoot, "src"), { recursive: true });

const targetPath = resolve(workspaceRoot, "src/target.ts");
await writeFile(targetPath, "export function doThing() {}\n");
const callerPath = resolve(workspaceRoot, "src/caller.ts");
await writeFile(
callerPath,
"import { doThing } from './target';\nexport function caller() {\n doThing();\n}\n",
);

const indexer = createIndexer(":memory:", wasmDir);
try {
await indexer.initialize();
await indexer.indexFile(targetPath, workspaceRoot);
await indexer.indexFile(callerPath, workspaceRoot);
await indexer.finalizeWorkspace(workspaceRoot);

// The callee symbol's stored location is what the resolver must point at.
const targetSymbol = (await indexer.getSymbols("src/target.ts")).find(
(s) => s.name === "doThing",
);
expect(targetSymbol).toBeDefined();

// Fake precise resolver: answers every site with the callee's location.
const resolver = {
tier: "precise" as const,
resolve: async () => [
{
name: "doThing",
filePath: targetPath,
line: targetSymbol!.range.startLine,
tier: "precise" as const,
confidence: 1,
},
],
};

const progress: number[] = [];
const summary = await indexer.resolvePreciseEdges(workspaceRoot, resolver, {
onProgress: (p) => progress.push(p.processed),
});

expect(summary.total).toBeGreaterThan(0);
expect(summary.upgraded).toBeGreaterThan(0);
expect(summary.cancelled).toBe(false);
expect(progress.length).toBe(summary.total);

// Re-running is a no-op upgrade: the precise edges drop out of the work-list.
const second = await indexer.resolvePreciseEdges(workspaceRoot, resolver);
expect(second.total).toBeLessThan(summary.total);
} finally {
await indexer.dispose();
}
});

it("resolvePreciseEdges leaves edges heuristic when the resolver answers nothing", async () => {
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "dextree-core-precise-empty-"));
tempDirs.push(workspaceRoot);
await mkdir(resolve(workspaceRoot, "src"), { recursive: true });
const targetPath = resolve(workspaceRoot, "src/target.ts");
await writeFile(targetPath, "export function doThing() {}\n");
const callerPath = resolve(workspaceRoot, "src/caller.ts");
await writeFile(
callerPath,
"import { doThing } from './target';\nexport function caller() {\n doThing();\n}\n",
);

const indexer = createIndexer(":memory:", wasmDir);
try {
await indexer.initialize();
await indexer.indexFile(targetPath, workspaceRoot);
await indexer.indexFile(callerPath, workspaceRoot);
await indexer.finalizeWorkspace(workspaceRoot);

const coldResolver = { tier: "precise" as const, resolve: async () => [] };
const summary = await indexer.resolvePreciseEdges(workspaceRoot, coldResolver);
expect(summary.upgraded).toBe(0);
// Nothing upgraded → the same sites are still offered next run.
const second = await indexer.resolvePreciseEdges(workspaceRoot, coldResolver);
expect(second.total).toBe(summary.total);
} finally {
await indexer.dispose();
}
});

it("resolvePreciseEdges honors cancellation and persists partial work", async () => {
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "dextree-core-precise-cancel-"));
tempDirs.push(workspaceRoot);
await mkdir(resolve(workspaceRoot, "src"), { recursive: true });
const targetPath = resolve(workspaceRoot, "src/target.ts");
await writeFile(targetPath, "export function doThing() {}\n");
const callerPath = resolve(workspaceRoot, "src/caller.ts");
await writeFile(
callerPath,
"import { doThing } from './target';\nexport function caller() {\n doThing();\n}\n",
);

const indexer = createIndexer(":memory:", wasmDir);
try {
await indexer.initialize();
await indexer.indexFile(targetPath, workspaceRoot);
await indexer.indexFile(callerPath, workspaceRoot);
await indexer.finalizeWorkspace(workspaceRoot);

const resolver = { tier: "precise" as const, resolve: async () => [] };
const summary = await indexer.resolvePreciseEdges(workspaceRoot, resolver, {
isCancelled: () => true, // cancel before the first site
});
expect(summary.cancelled).toBe(true);
expect(summary.upgraded).toBe(0);
} finally {
await indexer.dispose();
}
});
});

afterEach(async () => {
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { ExtractorRegistry } from "./extractors/types.js";
import { detectLanguage } from "./parser/extractor.js";
import { parseSource } from "./parser/grammars.js";
import type { NeighborhoodOptions, NeighborhoodResult } from "./query/neighborhood.js";
import type { PreciseLocationResolver, PreciseResolution } from "./resolution/types.js";
import { openDatabase } from "./storage/db.js";
import { DuckDbGraphRepository } from "./storage/adapters/duckdbGraphRepository.js";
import {
Expand All @@ -26,6 +27,8 @@ import {
type Indexer,
type IndexerFactoryOptions,
type Logger,
type PreciseResolutionOptions,
type PreciseResolutionSummary,
type SessionSummary,
type StoredFile,
type StoredSymbol,
Expand Down Expand Up @@ -58,6 +61,8 @@ export type {
IndexResult,
Indexer,
Logger,
PreciseResolutionOptions,
PreciseResolutionSummary,
SessionSummary,
StoredFile,
StoredSymbol,
Expand Down Expand Up @@ -96,6 +101,8 @@ export type {
NodeLocation,
PreciseCallEdge,
PreciseLocationResolver,
PreciseResolution,
UnresolvedCallSite,
} from "./resolution/types.js";
export type { ForeignWorkspaceGraph, WorkspaceIndexSummary } from "./storage/workspaceRegistry.js";
export type { ForeignGraphReader, GraphRepository } from "./storage/graphRepository.js";
Expand Down Expand Up @@ -352,6 +359,48 @@ class DuckTreeIndexer implements Indexer {
this.logger?.info("Finalized workspace cross-file edges + folder tree", { workspaceRoot });
}

async resolvePreciseEdges(
workspaceRoot: string,
resolver: PreciseLocationResolver,
options?: PreciseResolutionOptions,
): Promise<PreciseResolutionSummary> {
await this.initialize();
const repo = this.requireRepo();

const sites = await repo.getUnresolvedCallSites(workspaceRoot);
const resolutions: PreciseResolution[] = [];
let processed = 0;
let cancelled = false;

for (const site of sites) {
if (options?.isCancelled?.() === true) {
cancelled = true;
break;
}
// The user's language server answers by location. Take the first returned
// callee that maps back to a stored symbol; leave the edge heuristic if none.
const edges = await resolver.resolve(site.sourceLocation, "out");
for (const edge of edges) {
const targetId = await repo.findSymbolIdAt(edge.filePath, edge.line);
if (targetId !== null) {
resolutions.push({ edgeId: site.edgeId, targetId });
break;
}
}
processed += 1;
options?.onProgress?.({ processed, total: sites.length, upgraded: resolutions.length });
}

const upgraded = await repo.persistPreciseEdges(resolutions);
this.logger?.info("Precise resolution pass complete", {
workspaceRoot,
total: sites.length,
upgraded,
cancelled,
});
return { total: sites.length, upgraded, cancelled };
}

async neighborhood(nodeId: string, options: NeighborhoodOptions): Promise<NeighborhoodResult> {
// Fail fast at the boundary (RULE-ARCH-006) — an empty node id would scan
// from a non-existent seed and silently return nothing.
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/resolution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,20 @@ export interface PreciseLocationResolver {
readonly tier: "precise";
resolve(node: NodeLocation, direction: "in" | "out"): Promise<readonly PreciseCallEdge[]>;
}

/**
* A persisted `CALLS` edge still at the heuristic/unresolved tier, paired with the
* location of its source symbol — the work-list item for the precise (pass-2)
* resolution pass. The pass queries the language server at `sourceLocation` and,
* on a match, upgrades this edge.
*/
export interface UnresolvedCallSite {
edgeId: string;
sourceLocation: NodeLocation;
}

/** A precise resolution to persist: an edge now points at a confirmed target symbol. */
export interface PreciseResolution {
edgeId: string;
targetId: string;
}
18 changes: 18 additions & 0 deletions packages/core/src/storage/adapters/duckdbGraphRepository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EdgeRow } from "../../extractors/types.js";
import type { PreciseResolution } from "../../resolution/types.js";
import type { Logger } from "../../types.js";
import type {
CoverageReport,
Expand Down Expand Up @@ -28,6 +29,11 @@ import type { MigrationResult } from "../migrations/runner.js";
import { replaceFileGraph, replaceWorkspaceFrameworks, setFileFramework } from "../repository.js";
import type { DetectedFrameworkRow } from "../repository.js";
import { resolveWorkspaceCrossFileEdges, stampResolutionTier } from "../resolution.js";
import {
findSymbolIdAt,
getUnresolvedCallSites,
persistPreciseEdges,
} from "../preciseResolution.js";
import { synthesizeFolderTree } from "../folderTree.js";
import { initializeSchema } from "../schema.js";
import { writeWorkspaceCacheSnapshot, validateWorkspaceCache } from "../workspaceCache.js";
Expand Down Expand Up @@ -94,6 +100,18 @@ export class DuckDbGraphRepository implements GraphRepository {
return synthesizeFolderTree(this.connection);
}

getUnresolvedCallSites(workspaceRoot: string) {
return getUnresolvedCallSites(this.connection, workspaceRoot);
}

findSymbolIdAt(filePath: string, line: number): Promise<string | null> {
return findSymbolIdAt(this.connection, filePath, line);
}

persistPreciseEdges(resolutions: readonly PreciseResolution[]): Promise<number> {
return persistPreciseEdges(this.connection, resolutions);
}

getWorkspaceSubgraph(workspaceRoot: string): Promise<WorkspaceSubgraph> {
return getWorkspaceSubgraph(this.connection, workspaceRoot);
}
Expand Down
48 changes: 46 additions & 2 deletions packages/core/src/storage/fakeGraphRepository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EdgeRow } from "../extractors/types.js";
import type { NodeLocation, PreciseResolution, UnresolvedCallSite } from "../resolution/types.js";
import type {
CoverageReport,
ExtractedIndexData,
Expand All @@ -18,20 +19,41 @@ import type { MigrationResult } from "./migrations/runner.js";
import type { DetectedFrameworkRow } from "./repository.js";
import type { WriteWorkspaceCacheSnapshotInput } from "./workspaceCache.js";

/** In-memory CALLS edge for the precise-resolution round-trip (test modelling). */
interface FakeEdge {
edgeId: string;
source: NodeLocation;
targetId: string | null;
resolution: "heuristic" | "unresolved" | "precise";
}

/**
* In-memory {@link GraphRepository} for contract tests that need the interface
* without a DuckDB instance. It is a structural proof that the repository is
* substitutable (RULE-ARCH-003): the type checker enforces that this satisfies
* the whole interface, and it implements enough real behavior (per-file symbol
* storage, clears) for round-trip tests. Analytics-heavy reads return valid
* empty shapes — extend them in a test if a scenario needs richer behavior.
* storage, clears, precise-edge round-trip) for round-trip tests. Analytics-heavy
* reads return valid empty shapes — extend them in a test if a scenario needs
* richer behavior.
*/
export class FakeGraphRepository implements GraphRepository {
/** relativePath → file */
private readonly files = new Map<string, StoredFile>();
/** relativePath → symbols */
private readonly symbolsByFile = new Map<string, StoredSymbol[]>();
private readonly cacheSnapshots = new Map<string, WriteWorkspaceCacheSnapshotInput>();
/** CALLS edges, for the precise-resolution contract tests. */
private readonly callEdges: FakeEdge[] = [];
/** `${filePath}:${startLine}` → symbol id, for findSymbolIdAt. */
private readonly symbolByLocation = new Map<string, string>();

/** Test helper: seed a CALLS edge + the target symbol's location, for precise tests. */
seedCallSite(edge: FakeEdge, target?: { filePath: string; line: number; id: string }): void {
this.callEdges.push(edge);
if (target !== undefined) {
this.symbolByLocation.set(`${target.filePath}:${target.line}`, target.id);
}
}

async initializeSchema(): Promise<void> {}

Expand Down Expand Up @@ -66,6 +88,28 @@ export class FakeGraphRepository implements GraphRepository {

async synthesizeFolderTree(): Promise<void> {}

async getUnresolvedCallSites(_workspaceRoot: string): Promise<UnresolvedCallSite[]> {
return this.callEdges
.filter((e) => e.resolution !== "precise")
.map((e) => ({ edgeId: e.edgeId, sourceLocation: e.source }));
}

async findSymbolIdAt(filePath: string, line: number): Promise<string | null> {
return this.symbolByLocation.get(`${filePath}:${line}`) ?? null;
}

async persistPreciseEdges(resolutions: readonly PreciseResolution[]): Promise<number> {
let upgraded = 0;
for (const { edgeId, targetId } of resolutions) {
const edge = this.callEdges.find((e) => e.edgeId === edgeId);
if (edge === undefined) continue;
edge.targetId = targetId;
edge.resolution = "precise";
upgraded += 1;
}
return upgraded;
}

async getWorkspaceSubgraph(): Promise<WorkspaceSubgraph> {
return { nodes: [], edges: [], frameworks: [] };
}
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/storage/graphRepository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EdgeRow } from "../extractors/types.js";
import type { PreciseResolution, UnresolvedCallSite } from "../resolution/types.js";
import type {
CoverageReport,
ExtractedIndexData,
Expand Down Expand Up @@ -50,6 +51,14 @@ export interface GraphRepository {
stampResolutionTier(): Promise<void>;
synthesizeFolderTree(): Promise<void>;

// ---- precise (pass-2) resolution: persist LSP-resolved CALLS targets ----
/** `CALLS` edges still heuristic/unresolved, with their source symbol's location. */
getUnresolvedCallSites(workspaceRoot: string): Promise<UnresolvedCallSite[]>;
/** Map a language-server result location to a stored symbol id, or null on no/ambiguous match. */
findSymbolIdAt(filePath: string, line: number): Promise<string | null>;
/** Set target_id + stamp `metadata.resolution='precise'` for each resolution; returns the count upgraded. */
persistPreciseEdges(resolutions: readonly PreciseResolution[]): Promise<number>;

// ---- reads (the query surface consumed by the app + UI) ----
getWorkspaceSubgraph(workspaceRoot: string): Promise<WorkspaceSubgraph>;
getSymbolsForFile(relativePath: string): Promise<StoredSymbol[]>;
Expand Down
Loading
Loading