diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 082e4c9..64ab00f 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -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 () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c15126c..d237758 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { @@ -26,6 +27,8 @@ import { type Indexer, type IndexerFactoryOptions, type Logger, + type PreciseResolutionOptions, + type PreciseResolutionSummary, type SessionSummary, type StoredFile, type StoredSymbol, @@ -58,6 +61,8 @@ export type { IndexResult, Indexer, Logger, + PreciseResolutionOptions, + PreciseResolutionSummary, SessionSummary, StoredFile, StoredSymbol, @@ -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"; @@ -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 { + 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 { // Fail fast at the boundary (RULE-ARCH-006) — an empty node id would scan // from a non-existent seed and silently return nothing. diff --git a/packages/core/src/resolution/types.ts b/packages/core/src/resolution/types.ts index 980f4b6..3d28f3f 100644 --- a/packages/core/src/resolution/types.ts +++ b/packages/core/src/resolution/types.ts @@ -64,3 +64,20 @@ export interface PreciseLocationResolver { readonly tier: "precise"; resolve(node: NodeLocation, direction: "in" | "out"): Promise; } + +/** + * 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; +} diff --git a/packages/core/src/storage/adapters/duckdbGraphRepository.ts b/packages/core/src/storage/adapters/duckdbGraphRepository.ts index b7e3919..b406a8a 100644 --- a/packages/core/src/storage/adapters/duckdbGraphRepository.ts +++ b/packages/core/src/storage/adapters/duckdbGraphRepository.ts @@ -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, @@ -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"; @@ -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 { + return findSymbolIdAt(this.connection, filePath, line); + } + + persistPreciseEdges(resolutions: readonly PreciseResolution[]): Promise { + return persistPreciseEdges(this.connection, resolutions); + } + getWorkspaceSubgraph(workspaceRoot: string): Promise { return getWorkspaceSubgraph(this.connection, workspaceRoot); } diff --git a/packages/core/src/storage/fakeGraphRepository.ts b/packages/core/src/storage/fakeGraphRepository.ts index 029001e..9b1e9b3 100644 --- a/packages/core/src/storage/fakeGraphRepository.ts +++ b/packages/core/src/storage/fakeGraphRepository.ts @@ -1,4 +1,5 @@ import type { EdgeRow } from "../extractors/types.js"; +import type { NodeLocation, PreciseResolution, UnresolvedCallSite } from "../resolution/types.js"; import type { CoverageReport, ExtractedIndexData, @@ -18,13 +19,22 @@ 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 */ @@ -32,6 +42,18 @@ export class FakeGraphRepository implements GraphRepository { /** relativePath → symbols */ private readonly symbolsByFile = new Map(); private readonly cacheSnapshots = new Map(); + /** CALLS edges, for the precise-resolution contract tests. */ + private readonly callEdges: FakeEdge[] = []; + /** `${filePath}:${startLine}` → symbol id, for findSymbolIdAt. */ + private readonly symbolByLocation = new Map(); + + /** 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 {} @@ -66,6 +88,28 @@ export class FakeGraphRepository implements GraphRepository { async synthesizeFolderTree(): Promise {} + async getUnresolvedCallSites(_workspaceRoot: string): Promise { + return this.callEdges + .filter((e) => e.resolution !== "precise") + .map((e) => ({ edgeId: e.edgeId, sourceLocation: e.source })); + } + + async findSymbolIdAt(filePath: string, line: number): Promise { + return this.symbolByLocation.get(`${filePath}:${line}`) ?? null; + } + + async persistPreciseEdges(resolutions: readonly PreciseResolution[]): Promise { + 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 { return { nodes: [], edges: [], frameworks: [] }; } diff --git a/packages/core/src/storage/graphRepository.ts b/packages/core/src/storage/graphRepository.ts index e8b675c..af5cc22 100644 --- a/packages/core/src/storage/graphRepository.ts +++ b/packages/core/src/storage/graphRepository.ts @@ -1,4 +1,5 @@ import type { EdgeRow } from "../extractors/types.js"; +import type { PreciseResolution, UnresolvedCallSite } from "../resolution/types.js"; import type { CoverageReport, ExtractedIndexData, @@ -50,6 +51,14 @@ export interface GraphRepository { stampResolutionTier(): Promise; synthesizeFolderTree(): Promise; + // ---- precise (pass-2) resolution: persist LSP-resolved CALLS targets ---- + /** `CALLS` edges still heuristic/unresolved, with their source symbol's location. */ + getUnresolvedCallSites(workspaceRoot: string): Promise; + /** Map a language-server result location to a stored symbol id, or null on no/ambiguous match. */ + findSymbolIdAt(filePath: string, line: number): Promise; + /** Set target_id + stamp `metadata.resolution='precise'` for each resolution; returns the count upgraded. */ + persistPreciseEdges(resolutions: readonly PreciseResolution[]): Promise; + // ---- reads (the query surface consumed by the app + UI) ---- getWorkspaceSubgraph(workspaceRoot: string): Promise; getSymbolsForFile(relativePath: string): Promise; diff --git a/packages/core/src/storage/preciseResolution.test.ts b/packages/core/src/storage/preciseResolution.test.ts new file mode 100644 index 0000000..f60361a --- /dev/null +++ b/packages/core/src/storage/preciseResolution.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; + +import type { ExtractedIndexData } from "../types.js"; +import { openDatabase } from "./db.js"; +import { replaceFileGraph } from "./repository.js"; +import { resolveWorkspaceCrossFileEdges, stampResolutionTier } from "./resolution.js"; +import { + findSymbolIdAt, + getUnresolvedCallSites, + persistPreciseEdges, +} from "./preciseResolution.js"; +import { initializeSchema } from "./schema.js"; + +const WS = "/workspace"; + +/** Two files: `caller` calls `target` by name (cross-file → heuristic after resolve). */ +function callerFile(): ExtractedIndexData { + return { + file: { + id: "f-caller", + path: `${WS}/src/caller.ts`, + relativePath: "src/caller.ts", + language: "typescript", + loc: 5, + hash: "h-caller", + }, + symbols: [ + { + id: "s-caller", + fqn: "src/caller.ts:caller", + name: "caller", + kind: "function", + fileId: "f-caller", + range: { startLine: 2, startCol: 0, endLine: 4, endCol: 1 }, + language: "typescript", + }, + ], + imports: [], + }; +} + +function targetFile(): ExtractedIndexData { + return { + file: { + id: "f-target", + path: `${WS}/src/target.ts`, + relativePath: "src/target.ts", + language: "typescript", + loc: 5, + hash: "h-target", + }, + symbols: [ + { + id: "s-target", + fqn: "src/target.ts:doThing", + name: "doThing", + kind: "function", + fileId: "f-target", + range: { startLine: 10, startCol: 0, endLine: 12, endCol: 1 }, + language: "typescript", + }, + ], + imports: [], + }; +} + +/** Seed a CALLS edge from s-caller naming doThing, unresolved target. */ +async function seedCallEdge(connection: Awaited>["connection"]) { + await connection.run( + `INSERT INTO edge (id, source_id, target_id, kind, metadata) + VALUES ('e-call', 's-caller', NULL, 'CALLS', '{"calleeName":"doThing"}')`, + ); +} + +async function fresh() { + const db = await openDatabase(":memory:"); + await initializeSchema(db.connection); + await replaceFileGraph(db.connection, callerFile()); + await replaceFileGraph(db.connection, targetFile()); + await seedCallEdge(db.connection); + return db; +} + +async function edgeRow(connection: Awaited>["connection"]) { + const rows = await ( + await connection.run( + `SELECT target_id, json_extract_string(metadata, '$.resolution') AS res FROM edge WHERE id = 'e-call'`, + ) + ).getRowObjectsJS(); + return rows[0] as { target_id: string | null; res: string | null }; +} + +describe("preciseResolution (real DuckDB)", () => { + it("lists the unresolved CALLS site with its source location", async () => { + const db = await fresh(); + try { + const sites = await getUnresolvedCallSites(db.connection, WS); + expect(sites).toHaveLength(1); + expect(sites[0]).toEqual({ + edgeId: "e-call", + sourceLocation: { filePath: `${WS}/src/caller.ts`, line: 2, column: 0 }, + }); + } finally { + db.close(); + } + }); + + it("findSymbolIdAt maps a file+line to the target symbol id", async () => { + const db = await fresh(); + try { + expect(await findSymbolIdAt(db.connection, `${WS}/src/target.ts`, 10)).toBe("s-target"); + expect(await findSymbolIdAt(db.connection, `${WS}/src/target.ts`, 999)).toBeNull(); + } finally { + db.close(); + } + }); + + it("persistPreciseEdges sets target_id + precise tier", async () => { + const db = await fresh(); + try { + const n = await persistPreciseEdges(db.connection, [ + { edgeId: "e-call", targetId: "s-target" }, + ]); + expect(n).toBe(1); + expect(await edgeRow(db.connection)).toEqual({ target_id: "s-target", res: "precise" }); + } finally { + db.close(); + } + }); + + it("precise edge is not downgraded by a later stampResolutionTier, and drops out of the work-list", async () => { + const db = await fresh(); + try { + await persistPreciseEdges(db.connection, [{ edgeId: "e-call", targetId: "s-target" }]); + // A later heuristic re-stamp must NOT downgrade it. + await stampResolutionTier(db.connection); + expect(await edgeRow(db.connection)).toEqual({ target_id: "s-target", res: "precise" }); + // And it is no longer offered as an unresolved site. + expect(await getUnresolvedCallSites(db.connection, WS)).toHaveLength(0); + } finally { + db.close(); + } + }); + + it("a heuristic edge (resolved by name) is still offered for precise upgrade", async () => { + const db = await fresh(); + try { + await resolveWorkspaceCrossFileEdges(db.connection, WS); // sets target + heuristic tier + const sites = await getUnresolvedCallSites(db.connection, WS); + expect(sites.map((s) => s.edgeId)).toContain("e-call"); + } finally { + db.close(); + } + }); +}); diff --git a/packages/core/src/storage/preciseResolution.ts b/packages/core/src/storage/preciseResolution.ts new file mode 100644 index 0000000..3052d3c --- /dev/null +++ b/packages/core/src/storage/preciseResolution.ts @@ -0,0 +1,110 @@ +import type { NodeLocation, PreciseResolution, UnresolvedCallSite } from "../resolution/types.js"; +import type { GraphDbConnection } from "./db.js"; +import { runInTransaction } from "./db.js"; + +/** + * Precise (pass-2) resolution persistence. The heuristic pass resolves CALLS + * targets by name; this layer lets the host's language server upgrade them to the + * `precise` tier and write the result back, so the whole projected graph (not just + * an inspected node) reflects precise resolution. All SQL stays here, behind the + * repository — the LSP bridge lives in the extension (RULE-ARCH-005 / ARCH-010). + */ + +/** + * Every `CALLS` edge still at the heuristic/unresolved tier, paired with the + * location of its source symbol so the host can query the language server there. + * Edges already `precise` are skipped (never re-resolved / downgraded). + */ +export async function getUnresolvedCallSites( + connection: GraphDbConnection, + workspaceRoot: string, +): Promise { + const prefix = workspaceRoot.endsWith("/") ? workspaceRoot : `${workspaceRoot}/`; + const rows = await ( + await connection.run( + ` + SELECT e.id AS edge_id, + f.path AS file_path, + src.range.start_line AS start_line, + src.range.start_col AS start_col + FROM edge e + INNER JOIN symbol src ON src.id = e.source_id + INNER JOIN file f ON f.id = src.file_id + WHERE e.kind = 'CALLS' + AND COALESCE(json_extract_string(e.metadata, '$.resolution'), '') <> 'precise' + AND (f.path = $workspace_root OR f.path LIKE $workspace_prefix) + `, + { workspace_root: workspaceRoot, workspace_prefix: `${prefix}%` }, + ) + ).getRowObjectsJS(); + + return rows.map((row) => { + const location: NodeLocation = { + filePath: String(row.file_path), + line: Number(row.start_line), + column: Number(row.start_col), + }; + return { edgeId: String(row.edge_id), sourceLocation: location }; + }); +} + +/** + * Map a language-server result location back to a stored symbol id. Matches by + * file path + 0-based start line (call-hierarchy items point at a definition's + * start). Returns null when there is no match or the line is ambiguous — the + * caller then leaves the edge at its heuristic tier rather than guessing. + */ +export async function findSymbolIdAt( + connection: GraphDbConnection, + filePath: string, + line: number, +): Promise { + const rows = await ( + await connection.run( + ` + SELECT s.id AS id + FROM symbol s + INNER JOIN file f ON f.id = s.file_id + WHERE f.path = $path + AND s.range.start_line = $line + LIMIT 2 + `, + { path: filePath, line }, + ) + ).getRowObjectsJS(); + + if (rows.length !== 1) return null; // no match, or ambiguous — don't guess + return String(rows[0]!.id); +} + +/** + * Persist precise resolutions: set `target_id` and stamp + * `metadata.resolution='precise'` (confidence 1.0) for each edge, in one + * transaction. Idempotent — re-applying the same resolution is a no-op upgrade. + * Returns the number of edges updated. + */ +export async function persistPreciseEdges( + connection: GraphDbConnection, + resolutions: readonly PreciseResolution[], +): Promise { + if (resolutions.length === 0) return 0; + + await runInTransaction(connection, async () => { + for (const { edgeId, targetId } of resolutions) { + await connection.run( + ` + UPDATE edge + SET target_id = $target_id, + metadata = json_merge_patch( + metadata, + '{"resolution":"precise","confidence":1.0}' + ) + WHERE id = $edge_id + `, + { target_id: targetId, edge_id: edgeId }, + ); + } + }); + + return resolutions.length; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8f0aabc..7f0fff2 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -6,6 +6,8 @@ // 5 — adds workspace_framework table + file.framework columns (migration 005) // 6 — adds symbol.entry_kind + symbol.arch_layer classification columns (migration 006) // 7 — adds symbol.enclosing_symbol_id classification column (migration 007) +import type { PreciseLocationResolver } from "./resolution/types.js"; + export const SCHEMA_VERSION = 8; export interface Logger { @@ -324,9 +326,38 @@ export interface Indexer { clearWorkspace(workspaceRoot: string): Promise; clearFile(filePath: string): Promise; clearAll(): Promise; + /** + * Pass-2 precise resolution: upgrade persisted heuristic/unresolved `CALLS` + * edges to the `precise` tier using a host-supplied location resolver (the + * user's language server). Opt-in, idempotent, behavior-preserving for the + * default index. The host owns the resolver (RULE-ARCH-005); the indexer owns + * the work-list + persistence (all DB access through the repository). + */ + resolvePreciseEdges( + workspaceRoot: string, + resolver: PreciseLocationResolver, + options?: PreciseResolutionOptions, + ): Promise; dispose(): Promise; } +/** Host hooks for the precise pass — progress + cancellation, no VS Code dependency. */ +export interface PreciseResolutionOptions { + /** Reports incremental progress; `processed`/`total` sites, `upgraded` so far. */ + onProgress?: (progress: { processed: number; total: number; upgraded: number }) => void; + /** Polled between sites; return true to stop early (persisting what's done). */ + isCancelled?: () => boolean; +} + +export interface PreciseResolutionSummary { + /** Total unresolved call sites considered. */ + total: number; + /** Sites the resolver answered and matched to a target. */ + upgraded: number; + /** Whether the pass stopped early due to cancellation. */ + cancelled: boolean; +} + export interface ExtractedFileRecord { id: string; path: string; diff --git a/packages/extension/package.json b/packages/extension/package.json index 331d767..f6d3042 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -122,6 +122,10 @@ "command": "dextree.clearAllIndex", "title": "Dextree: Clear All Indexed Data" }, + { + "command": "dextree.resolvePreciseEdges", + "title": "Dextree: Resolve Precise Edges (LSP)" + }, { "command": "dextree.cancelWorkspaceIndexing", "title": "Dextree: Cancel Workspace Indexing", diff --git a/packages/extension/src/commands/indexFile.test.ts b/packages/extension/src/commands/indexFile.test.ts index 819e2f7..a8d7aa8 100644 --- a/packages/extension/src/commands/indexFile.test.ts +++ b/packages/extension/src/commands/indexFile.test.ts @@ -56,6 +56,7 @@ function createMockIndexer() { getSessionSummary: vi.fn().mockResolvedValue(null), detectWorkspaceFrameworks: vi.fn().mockResolvedValue([]), finalizeWorkspace: vi.fn().mockResolvedValue(undefined), + resolvePreciseEdges: vi.fn().mockResolvedValue({ total: 0, upgraded: 0, cancelled: false }), dispose: vi.fn(), }; } diff --git a/packages/extension/src/commands/indexWorkspace.test.ts b/packages/extension/src/commands/indexWorkspace.test.ts index 61e5fd6..8db1cad 100644 --- a/packages/extension/src/commands/indexWorkspace.test.ts +++ b/packages/extension/src/commands/indexWorkspace.test.ts @@ -43,6 +43,7 @@ function createMockIndexer() { elapsedMs: 5, }), finalizeWorkspace: vi.fn().mockResolvedValue(undefined), + resolvePreciseEdges: vi.fn().mockResolvedValue({ total: 0, upgraded: 0, cancelled: false }), detectWorkspaceFrameworks: vi.fn().mockResolvedValue([]), validateWorkspaceCache: vi.fn(), getSymbols: vi.fn(), diff --git a/packages/extension/src/commands/resolvePreciseEdges.test.ts b/packages/extension/src/commands/resolvePreciseEdges.test.ts new file mode 100644 index 0000000..cc4f37c --- /dev/null +++ b/packages/extension/src/commands/resolvePreciseEdges.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Indexer, PreciseResolutionSummary } from "@dextree/core"; + +const { mockShowInformationMessage, mockWithProgress } = vi.hoisted(() => ({ + mockShowInformationMessage: vi.fn(), + mockWithProgress: vi.fn(), +})); + +vi.mock("vscode", () => ({ + window: { + showInformationMessage: mockShowInformationMessage, + withProgress: mockWithProgress, + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/workspace/proj" }, name: "proj" }], + }, + ProgressLocation: { Notification: 15 }, + Uri: { file: (p: string) => ({ fsPath: p }) }, +})); + +// LspCallResolver imports vscode; the mock above satisfies it. The mock indexer +// never invokes the resolver, so its behavior is irrelevant to these tests. +import { createResolvePreciseEdgesCommand } from "./resolvePreciseEdges.js"; + +function makeLogger() { + return { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), dispose: vi.fn() }; +} + +/** Run the withProgress callback immediately with a fake progress + token. */ +function runProgressInline(cancelled = false) { + mockWithProgress.mockImplementation( + async (_opts: unknown, task: (p: unknown, t: unknown) => Promise) => + task({ report: vi.fn() }, { isCancellationRequested: cancelled }), + ); +} + +function makeIndexer(summary: PreciseResolutionSummary): Indexer { + return { + resolvePreciseEdges: vi.fn().mockResolvedValue(summary), + } as unknown as Indexer; +} + +beforeEach(() => { + mockShowInformationMessage.mockReset(); + mockWithProgress.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createResolvePreciseEdgesCommand", () => { + it("runs the indexer pass and reports the upgrade count", async () => { + runProgressInline(); + const indexer = makeIndexer({ total: 12, upgraded: 9, cancelled: false }); + const onResolved = vi.fn(); + const command = createResolvePreciseEdgesCommand({ + logger: makeLogger(), + getIndexer: async () => indexer, + onResolved, + }); + + await command(); + + expect(indexer.resolvePreciseEdges).toHaveBeenCalledWith( + "/workspace/proj", + expect.anything(), + expect.objectContaining({ isCancelled: expect.any(Function) }), + ); + expect(onResolved).toHaveBeenCalledTimes(1); + expect(mockShowInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("upgraded 9 of 12"), + ); + }); + + it("reports the empty case without refreshing", async () => { + runProgressInline(); + const indexer = makeIndexer({ total: 0, upgraded: 0, cancelled: false }); + const onResolved = vi.fn(); + await createResolvePreciseEdgesCommand({ + logger: makeLogger(), + getIndexer: async () => indexer, + onResolved, + })(); + + expect(onResolved).not.toHaveBeenCalled(); + expect(mockShowInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("no unresolved call edges"), + ); + }); + + it("surfaces cancellation in the message", async () => { + runProgressInline(true); + const indexer = makeIndexer({ total: 10, upgraded: 4, cancelled: true }); + await createResolvePreciseEdgesCommand({ + logger: makeLogger(), + getIndexer: async () => indexer, + })(); + + expect(mockShowInformationMessage).toHaveBeenCalledWith(expect.stringContaining("cancelled")); + }); +}); diff --git a/packages/extension/src/commands/resolvePreciseEdges.ts b/packages/extension/src/commands/resolvePreciseEdges.ts new file mode 100644 index 0000000..47b48ea --- /dev/null +++ b/packages/extension/src/commands/resolvePreciseEdges.ts @@ -0,0 +1,73 @@ +import type { Indexer } from "@dextree/core"; +import * as vscode from "vscode"; + +import type { Logger } from "../logger.js"; +import { LspCallResolver } from "../resolution/lspCallResolver.js"; + +export interface ResolvePreciseEdgesDependencies { + logger: Logger; + getIndexer: () => Promise; + /** Called after a successful pass so the graph view can re-project precise edges. */ + onResolved?: () => void; +} + +/** + * `Dextree: Resolve precise edges (LSP)` — the opt-in pass-2 that upgrades + * persisted heuristic `CALLS` edges to the precise tier using the user's language + * server, so the whole projected graph (not just an inspected node) is precise. + * Runs under a cancellable progress notification; the indexer owns the work-list + * + persistence, this command owns the LSP resolver + the UI. + */ +export function createResolvePreciseEdgesCommand( + deps: ResolvePreciseEdgesDependencies, +): () => Promise { + return async () => { + const root = vscode.workspace.workspaceFolders?.[0]; + if (root === undefined) { + await vscode.window.showInformationMessage("Dextree requires an open workspace folder."); + return; + } + + const indexer = await deps.getIndexer(); + const resolver = new LspCallResolver(); + + const summary = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Dextree: resolving precise edges (LSP)…", + cancellable: true, + }, + async (progress, token) => { + let lastPct = 0; + return indexer.resolvePreciseEdges(root.uri.fsPath, resolver, { + isCancelled: () => token.isCancellationRequested, + onProgress: ({ processed, total, upgraded }) => { + if (total === 0) return; + const pct = Math.floor((processed / total) * 100); + // Report only the incremental delta vsCode expects, and a readable message. + progress.report({ + increment: pct - lastPct, + message: `${processed}/${total} sites · ${upgraded} upgraded`, + }); + lastPct = pct; + }, + }); + }, + ); + + deps.logger.info( + `Precise resolution: ${summary.upgraded}/${summary.total} CALLS upgraded${summary.cancelled ? " (cancelled)" : ""}`, + ); + + if (summary.upgraded > 0) { + deps.onResolved?.(); + } + + const tail = summary.cancelled ? " (cancelled — partial results saved)" : ""; + await vscode.window.showInformationMessage( + summary.total === 0 + ? "Dextree: no unresolved call edges to upgrade." + : `Dextree: upgraded ${summary.upgraded} of ${summary.total} call edge(s) to precise${tail}.`, + ); + }; +} diff --git a/packages/extension/src/extension.test.ts b/packages/extension/src/extension.test.ts index da1c53f..55f0f70 100644 --- a/packages/extension/src/extension.test.ts +++ b/packages/extension/src/extension.test.ts @@ -345,6 +345,7 @@ describe("activate", () => { .mockResolvedValue({ deletedFiles: 0, deletedSymbols: 0, deletedEdges: 0 }), clearAll: vi.fn().mockResolvedValue({ clearedTables: 0 }), finalizeWorkspace: vi.fn().mockResolvedValue(undefined), + resolvePreciseEdges: vi.fn().mockResolvedValue({ total: 0, upgraded: 0, cancelled: false }), detectWorkspaceFrameworks: vi.fn().mockResolvedValue([]), getSessionSummary: vi.fn().mockResolvedValue({}), dispose: vi.fn(), diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index a3473db..eb43530 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -33,6 +33,7 @@ import { type IndexWorkspaceProgressUpdate, } from "./commands/indexWorkspace.js"; import { registerOpenGraphViewCommand } from "./commands/openGraphView.js"; +import { createResolvePreciseEdgesCommand } from "./commands/resolvePreciseEdges.js"; import { createLogger, type Logger } from "./logger.js"; import { LspCallResolver } from "./resolution/lspCallResolver.js"; import { SymbolsTreeProvider } from "./tree/SymbolsTreeProvider.js"; @@ -411,6 +412,14 @@ export async function activate(context: ActivationContext): Promise { onCleared: refreshViewsAfterIndex, }), ), + vscode.commands.registerCommand( + "dextree.resolvePreciseEdges", + createResolvePreciseEdgesCommand({ + logger, + getIndexer, + onResolved: refreshViewsAfterIndex, + }), + ), vscode.commands.registerCommand( "dextree.exportSessionSummary", createExportSessionSummaryCommand({ getIndexer }), diff --git a/packages/extension/src/watcher/workspaceWatcher.test.ts b/packages/extension/src/watcher/workspaceWatcher.test.ts index c5245dc..4c2b68d 100644 --- a/packages/extension/src/watcher/workspaceWatcher.test.ts +++ b/packages/extension/src/watcher/workspaceWatcher.test.ts @@ -35,6 +35,7 @@ function makeMockIndexer() { clearFile: mockClearFile, getAllFiles: mockGetAllFiles, finalizeWorkspace: vi.fn().mockResolvedValue(undefined), + resolvePreciseEdges: vi.fn().mockResolvedValue({ total: 0, upgraded: 0, cancelled: false }), detectWorkspaceFrameworks: vi.fn().mockResolvedValue([]), getSymbols: vi.fn(), initialize: vi.fn(),