From 6d47fd762e4fd67b3f0a24706fa4d682b9e253f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Sat, 27 Jun 2026 12:05:42 +0000 Subject: [PATCH 1/4] feat(repoMap): add regex-based symbol extraction engine Co-Authored-By: Claude --- src/llm/repoMap.ts | 292 ++++++++++++++++++++++++++++++++++++++ tests/llm/repoMap.test.ts | 39 +++++ 2 files changed, 331 insertions(+) create mode 100644 src/llm/repoMap.ts create mode 100644 tests/llm/repoMap.test.ts diff --git a/src/llm/repoMap.ts b/src/llm/repoMap.ts new file mode 100644 index 0000000..9709df0 --- /dev/null +++ b/src/llm/repoMap.ts @@ -0,0 +1,292 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {execFile as execFileCallback} from 'node:child_process'; +import {promisify} from 'node:util'; +import {readSettings, type HazeSettings} from '../config/settings.js'; +import {installedLspServers} from '../config/lspSettings.js'; +import {walkDir} from '../utils/fs.js'; +import {resolveWorkspacePath, workspaceRoot} from '../utils/path.js'; +import {isGitIgnored} from './tools/fileToolShared.js'; +import {lspWorkspaceSymbols} from './lsp.js'; + +const execFile = promisify(execFileCallback); + +export type RepoMapSymbolKind = + | 'class' + | 'interface' + | 'type' + | 'function' + | 'variable' + | 'method' + | 'unknown'; + +export interface RepoMapSymbol { + name: string; + kind: RepoMapSymbolKind; + path: string; + line: number; + column: number; +} + +const DECLARATION_PATTERNS: Array<{kind: RepoMapSymbolKind; regex: RegExp}> = [ + {kind: 'class', regex: /^\s*(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)\b/}, + {kind: 'interface', regex: /^\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)\b/}, + {kind: 'type', regex: /^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\b/}, + {kind: 'function', regex: /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\b/}, + {kind: 'variable', regex: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b/}, + {kind: 'method', regex: /^\s*(?:private\s+|public\s+|protected\s+|static\s+|async\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?:\{|:)/}, +]; + +function isInsideStringLiteral(line: string, index: number): boolean { + let inside: '"' | "'" | '`' | undefined; + let escaped = false; + for (let i = 0; i < index; i++) { + const char = line[i]; + if (!char) continue; + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (inside) { + if (char === inside) inside = undefined; + continue; + } + if (char === '"' || char === "'" || char === '`') { + inside = char; + } + } + return inside !== undefined; +} + +function isCommentLine(line: string): boolean { + return /^\s*\/\//.test(line); +} + +export function extractSymbolsFromSource(filePath: string, source: string): RepoMapSymbol[] { + const symbols: RepoMapSymbol[] = []; + const lines = source.split(/\r?\n/); + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]!; + if (isCommentLine(line)) continue; + + for (const {kind, regex} of DECLARATION_PATTERNS) { + const match = regex.exec(line); + if (!match?.[1]) continue; + const name = match[1]; + const nameIndex = line.indexOf(name, match.index); + if (nameIndex === -1 || isInsideStringLiteral(line, nameIndex)) continue; + + symbols.push({ + name, + kind, + path: filePath, + line: index + 1, + column: nameIndex + 1, + }); + break; + } + } + + return symbols; +} + +function lspKindToSymbolKind(kind?: number): RepoMapSymbolKind { + switch (kind) { + case 5: return 'class'; + case 11: return 'interface'; + case 22: return 'type'; + case 12: return 'function'; + case 13: return 'variable'; + case 6: return 'method'; + case 9: return 'method'; + default: return 'unknown'; + } +} + +export async function extractSymbolsViaLsp( + settings: HazeSettings, + query: string, + limit: number +): Promise { + const servers = await installedLspServers(settings); + if (servers.length === 0) return []; + + const results: RepoMapSymbol[] = []; + + for (const server of servers) { + try { + const values = await lspWorkspaceSymbols(server, query, limit); + for (const value of values) { + results.push({ + name: value.name, + kind: lspKindToSymbolKind(value.kind), + path: value.path, + line: value.range?.start.line ?? 1, + column: value.range?.start.character ?? 1, + }); + } + } catch { + // Fall through to document-symbol fallback per server. + } + + if (results.length >= limit) break; + } + + return results.slice(0, limit); +} + +const CACHE_FILE = path.join(os.homedir(), '.haze', 'repo-map-cache.json'); + +interface RepoMapCacheEntry { + mtime: number; + head: string; + symbols: RepoMapSymbol[]; +} + +export interface RepoMapOptions { + path?: string; + maxSymbols?: number; + useLsp?: boolean; +} + +export interface RepoMapResult { + symbols: RepoMapSymbol[]; + truncated: boolean; + source: 'lsp' | 'regex'; +} + +async function gitHead(): Promise { + try { + const {stdout} = await execFile('git', ['-C', workspaceRoot(), 'rev-parse', 'HEAD']); + return stdout.trim(); + } catch { + return ''; + } +} + +async function recentlyTouchedFiles(maxCommits = 50): Promise> { + try { + const {stdout} = await execFile('git', ['-C', workspaceRoot(), 'log', `--max-count=${maxCommits}`, '--format=', '--name-only']); + return new Set(stdout.split('\n').map(line => line.trim()).filter(Boolean)); + } catch { + return new Set(); + } +} + +async function loadCache(): Promise> { + try { + const raw = await fs.readFile(CACHE_FILE, 'utf8'); + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} + +async function saveCache(cache: Record): Promise { + await fs.mkdir(path.dirname(CACHE_FILE), {recursive: true}); + await fs.writeFile(CACHE_FILE, JSON.stringify(cache), 'utf8'); +} + +export function rankSymbols( + symbols: RepoMapSymbol[], + referenceCounts: Map, + recentFiles: Set, + maxSymbols: number +): RepoMapSymbol[] { + const scored = symbols.map(symbol => { + const refs = referenceCounts.get(symbol.name) ?? 0; + const recent = recentFiles.has(symbol.path) ? 10 : 0; + return {...symbol, score: refs + recent}; + }); + + scored.sort((a, b) => (b.score - a.score) || a.name.localeCompare(b.name)); + return scored.slice(0, maxSymbols); +} + +export async function computeReferenceCounts( + symbols: RepoMapSymbol[], + fileContents: Map +): Promise> { + const counts = new Map(); + const names = new Set(symbols.map(symbol => symbol.name)); + const identifierPattern = /[A-Za-z_$][\w$]*/g; + + for (const content of fileContents.values()) { + let match: RegExpExecArray | null; + while ((match = identifierPattern.exec(content)) !== null) { + const name = match[0]; + if (names.has(name)) { + counts.set(name, (counts.get(name) ?? 0) + 1); + } + } + } + + return counts; +} + +export async function buildRepoMap(options: RepoMapOptions = {}): Promise { + const settings = await readSettings(); + const maxSymbols = options.maxSymbols ?? 200; + const scopeRoot = options.path ? resolveWorkspacePath(options.path) : workspaceRoot(); + + let symbols: RepoMapSymbol[] = []; + let source: 'lsp' | 'regex' = 'regex'; + + if (options.useLsp !== false) { + symbols = await extractSymbolsViaLsp(settings, '', maxSymbols * 2); + if (symbols.length > 0) source = 'lsp'; + } + + const cache = await loadCache(); + const head = await gitHead(); + const recentFiles = await recentlyTouchedFiles(); + const fileContents = new Map(); + + if (source === 'regex') { + const entries = await walkDir(scopeRoot, { + recursive: true, + filter: async entry => entry.isFile && !await isGitIgnored(entry.absolutePath), + }); + + for (const entry of entries) { + const cached = cache[entry.path]; + const stat = await fs.stat(entry.absolutePath); + + if (cached && cached.mtime === stat.mtimeMs && cached.head === head) { + symbols.push(...cached.symbols); + continue; + } + + const content = await fs.readFile(entry.absolutePath, 'utf8').catch(() => ''); + const extracted = extractSymbolsFromSource(entry.path, content); + symbols.push(...extracted); + fileContents.set(entry.path, content); + cache[entry.path] = {mtime: stat.mtimeMs, head, symbols: extracted}; + } + + await saveCache(cache); + } else { + for (const symbol of symbols) { + const absolutePath = path.join(workspaceRoot(), symbol.path); + if (!fileContents.has(symbol.path)) { + const content = await fs.readFile(absolutePath, 'utf8').catch(() => ''); + fileContents.set(symbol.path, content); + } + } + } + + const referenceCounts = await computeReferenceCounts(symbols, fileContents); + const ranked = rankSymbols(symbols, referenceCounts, recentFiles, maxSymbols); + + return { + symbols: ranked, + truncated: symbols.length > maxSymbols, + source, + }; +} diff --git a/tests/llm/repoMap.test.ts b/tests/llm/repoMap.test.ts new file mode 100644 index 0000000..b186c79 --- /dev/null +++ b/tests/llm/repoMap.test.ts @@ -0,0 +1,39 @@ +import {describe, expect, it} from 'vitest'; +import {extractSymbolsFromSource} from '../../src/llm/repoMap.js'; + +describe('extractSymbolsFromSource', () => { + it('extracts top-level TypeScript declarations', () => { + const source = [ + 'import {z} from "zod";', + 'export interface Config { key: string; }', + 'function helper() { return 1; }', + 'export class Builder {', + ' private run() {}', + '}', + 'export const DEFAULT_LIMIT = 100;', + ].join('\n'); + + const symbols = extractSymbolsFromSource('src/app.ts', source); + + expect(symbols.map(symbol => symbol.name)).toEqual( + expect.arrayContaining(['Config', 'helper', 'Builder', 'DEFAULT_LIMIT']) + ); + expect(symbols.find(symbol => symbol.name === 'Builder')?.kind).toBe('class'); + expect(symbols.find(symbol => symbol.name === 'Config')?.kind).toBe('interface'); + expect(symbols.find(symbol => symbol.name === 'helper')?.line).toBe(3); + }); + + it('ignores declarations inside string literals', () => { + const source = 'const x = "export class Fake {}";\nexport class Real {}'; + const symbols = extractSymbolsFromSource('src/fake.ts', source); + expect(symbols.map(symbol => symbol.name)).not.toContain('Fake'); + expect(symbols.map(symbol => symbol.name)).toContain('Real'); + }); + + it('skips comment lines that look like declarations', () => { + const source = '// export class Commented {}\nexport class Active {}'; + const symbols = extractSymbolsFromSource('src/comment.ts', source); + expect(symbols.map(symbol => symbol.name)).not.toContain('Commented'); + expect(symbols.map(symbol => symbol.name)).toContain('Active'); + }); +}); From 0093cfdde687b4252f139b21a09b3e08f9660763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Sat, 27 Jun 2026 12:25:38 +0000 Subject: [PATCH 2/4] feat(repoMap): add LSP-first extraction, ranking, and mtime/HEAD cache Co-Authored-By: Claude --- src/llm/repoMap.ts | 23 ++--- tests/llm/repoMap.test.ts | 177 +++++++++++++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 13 deletions(-) diff --git a/src/llm/repoMap.ts b/src/llm/repoMap.ts index 9709df0..85676d6 100644 --- a/src/llm/repoMap.ts +++ b/src/llm/repoMap.ts @@ -141,7 +141,9 @@ export async function extractSymbolsViaLsp( return results.slice(0, limit); } -const CACHE_FILE = path.join(os.homedir(), '.haze', 'repo-map-cache.json'); +const CACHE_FILE = process.env.HAZE_REPO_MAP_CACHE + ? path.resolve(process.env.HAZE_REPO_MAP_CACHE) + : path.join(os.homedir(), '.haze', 'repo-map-cache.json'); interface RepoMapCacheEntry { mtime: number; @@ -206,7 +208,7 @@ export function rankSymbols( }); scored.sort((a, b) => (b.score - a.score) || a.name.localeCompare(b.name)); - return scored.slice(0, maxSymbols); + return scored.slice(0, maxSymbols).map(({score: _score, ...symbol}) => symbol); } export async function computeReferenceCounts( @@ -251,7 +253,7 @@ export async function buildRepoMap(options: RepoMapOptions = {}): Promise entry.isFile && !await isGitIgnored(entry.absolutePath), + filter: async entry => !await isGitIgnored(entry.absolutePath), }); for (const entry of entries) { @@ -271,14 +273,13 @@ export async function buildRepoMap(options: RepoMapOptions = {}): Promise ''); - fileContents.set(symbol.path, content); - } - } + } + + for (const symbol of symbols) { + if (fileContents.has(symbol.path)) continue; + const absolutePath = path.join(workspaceRoot(), symbol.path); + const content = await fs.readFile(absolutePath, 'utf8').catch(() => ''); + fileContents.set(symbol.path, content); } const referenceCounts = await computeReferenceCounts(symbols, fileContents); diff --git a/tests/llm/repoMap.test.ts b/tests/llm/repoMap.test.ts index b186c79..64481ae 100644 --- a/tests/llm/repoMap.test.ts +++ b/tests/llm/repoMap.test.ts @@ -1,5 +1,37 @@ -import {describe, expect, it} from 'vitest'; -import {extractSymbolsFromSource} from '../../src/llm/repoMap.js'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {afterAll, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const testCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haze-repomap-cache-')); +process.env.HAZE_REPO_MAP_CACHE = path.join(testCacheDir, 'repo-map-cache.json'); + +const mocks = vi.hoisted(() => ({ + lspWorkspaceSymbols: vi.fn(), + installedLspServers: vi.fn(), + readSettings: vi.fn(async () => ({})), +})); + +vi.mock('../../src/llm/lsp.js', () => ({ + lspWorkspaceSymbols: mocks.lspWorkspaceSymbols, +})); + +vi.mock('../../src/config/lspSettings.js', () => ({ + installedLspServers: mocks.installedLspServers, +})); + +vi.mock('../../src/config/settings.js', () => ({ + readSettings: mocks.readSettings, +})); + +import { + extractSymbolsFromSource, + extractSymbolsViaLsp, + rankSymbols, + computeReferenceCounts, + buildRepoMap, + type RepoMapSymbol, +} from '../../src/llm/repoMap.js'; describe('extractSymbolsFromSource', () => { it('extracts top-level TypeScript declarations', () => { @@ -37,3 +69,144 @@ describe('extractSymbolsFromSource', () => { expect(symbols.map(symbol => symbol.name)).toContain('Active'); }); }); + +describe('extractSymbolsViaLsp', () => { + beforeEach(() => { + mocks.lspWorkspaceSymbols.mockReset(); + mocks.installedLspServers.mockReset(); + mocks.readSettings.mockReset(); + }); + + it('normalizes workspace symbols into RepoMapSymbol rows', async () => { + mocks.installedLspServers.mockResolvedValue([{name: 'typescript', command: 'typescript-language-server'}]); + mocks.lspWorkspaceSymbols.mockResolvedValue([ + {name: 'User', kind: 5, path: 'src/models.ts', range: {start: {line: 11, character: 3}}}, + ]); + + const result = await extractSymbolsViaLsp({}, '', 10); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'User', + kind: 'class', + path: 'src/models.ts', + line: 11, + column: 3, + }); + }); + + it('returns an empty array when no servers are installed', async () => { + mocks.installedLspServers.mockResolvedValue([]); + const result = await extractSymbolsViaLsp({}, '', 10); + expect(result).toEqual([]); + }); + + it('falls back to an empty array when a server throws', async () => { + mocks.installedLspServers.mockResolvedValue([{name: 'typescript', command: 'typescript-language-server'}]); + mocks.lspWorkspaceSymbols.mockRejectedValue(new Error('server crashed')); + const result = await extractSymbolsViaLsp({}, '', 10); + expect(result).toEqual([]); + }); +}); + +describe('rankSymbols', () => { + it('ranks frequently referenced symbols higher', () => { + const symbols: RepoMapSymbol[] = [ + {name: 'alpha', kind: 'function', path: 'src/a.ts', line: 1, column: 1}, + {name: 'beta', kind: 'function', path: 'src/b.ts', line: 1, column: 1}, + ]; + const refs = new Map([['alpha', 5], ['beta', 1]]); + const recent = new Set(); + const ranked = rankSymbols(symbols, refs, recent, 10); + + expect(ranked[0]!.name).toBe('alpha'); + }); + + it('boosts symbols in recently touched files', () => { + const symbols: RepoMapSymbol[] = [ + {name: 'old', kind: 'function', path: 'src/old.ts', line: 1, column: 1}, + {name: 'new', kind: 'function', path: 'src/new.ts', line: 1, column: 1}, + ]; + const refs = new Map(); + const recent = new Set(['src/new.ts']); + const ranked = rankSymbols(symbols, refs, recent, 10); + + expect(ranked[0]!.name).toBe('new'); + }); + + it('caps the result to maxSymbols', () => { + const symbols: RepoMapSymbol[] = Array.from({length: 5}, (_, index) => ({ + name: `s${index}`, + kind: 'function', + path: `src/${index}.ts`, + line: 1, + column: 1, + })); + const ranked = rankSymbols(symbols, new Map(), new Set(), 2); + expect(ranked).toHaveLength(2); + }); +}); + +describe('computeReferenceCounts', () => { + it('counts how many times each symbol name appears across sources', async () => { + const symbols: RepoMapSymbol[] = [ + {name: 'helper', kind: 'function', path: 'src/a.ts', line: 1, column: 1}, + ]; + const counts = await computeReferenceCounts(symbols, new Map([['src/a.ts', 'function helper() {} const x = helper();']])); + expect(counts.get('helper')).toBe(2); + }); +}); + +describe('buildRepoMap smoke', () => { + let tmp: string; + let originalCwd: string; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'haze-repomap-')); + originalCwd = process.cwd(); + process.chdir(tmp); + await fs.mkdir(path.join(tmp, 'src'), {recursive: true}); + await fs.writeFile( + path.join(tmp, 'src', 'math.ts'), + 'export function add(a: number, b: number) { return a + b; }\n', + 'utf8' + ); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.rm(tmp, {recursive: true, force: true}); + }); + + it('builds a map for a tiny workspace without LSP', async () => { + mocks.installedLspServers.mockResolvedValue([]); + mocks.readSettings.mockResolvedValue({}); + const result = await buildRepoMap({maxSymbols: 50, useLsp: true}); + expect(result.symbols.some(symbol => symbol.name === 'add')).toBe(true); + expect(result.source).toBe('regex'); + }); + + it('uses the LSP source when workspace symbols are returned', async () => { + mocks.installedLspServers.mockResolvedValue([{name: 'typescript', command: 'typescript-language-server'}]); + mocks.lspWorkspaceSymbols.mockResolvedValue([ + {name: 'add', kind: 12, path: 'src/math.ts', range: {start: {line: 0, character: 16}}}, + ]); + const result = await buildRepoMap({maxSymbols: 50, useLsp: true}); + expect(result.source).toBe('lsp'); + expect(result.symbols.some(symbol => symbol.name === 'add')).toBe(true); + }); + + it('reuses cached symbols on a second call with unchanged files', async () => { + mocks.installedLspServers.mockResolvedValue([]); + mocks.readSettings.mockResolvedValue({}); + const first = await buildRepoMap({maxSymbols: 50, useLsp: true}); + expect(first.source).toBe('regex'); + + const second = await buildRepoMap({maxSymbols: 50, useLsp: true}); + expect(second.symbols).toEqual(first.symbols); + }); +}); + +afterAll(async () => { + await fs.rm(testCacheDir, {recursive: true, force: true}); +}); From 20fa623595b95dd0132e625f4afad4b349e4be8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Mon, 29 Jun 2026 00:31:04 +0000 Subject: [PATCH 3/4] rebase: apply review feedback on #49 --- src/llm/repoMap.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/llm/repoMap.ts b/src/llm/repoMap.ts index 85676d6..4c3decc 100644 --- a/src/llm/repoMap.ts +++ b/src/llm/repoMap.ts @@ -123,6 +123,7 @@ export async function extractSymbolsViaLsp( try { const values = await lspWorkspaceSymbols(server, query, limit); for (const value of values) { + if (!value.path) continue; results.push({ name: value.name, kind: lspKindToSymbolKind(value.kind), From b5c3a541008aad95b210489b2830b1f86b35d996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Tue, 30 Jun 2026 01:46:31 +0000 Subject: [PATCH 4/4] rebase: apply review feedback on #49 --- src/llm/repoMap.ts | 47 ++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/llm/repoMap.ts b/src/llm/repoMap.ts index 4c3decc..2d8be9f 100644 --- a/src/llm/repoMap.ts +++ b/src/llm/repoMap.ts @@ -132,8 +132,9 @@ export async function extractSymbolsViaLsp( column: value.range?.start.character ?? 1, }); } - } catch { - // Fall through to document-symbol fallback per server. + } catch (error) { + console.warn(`LSP workspace-symbol query failed for ${server.name}:`, error); + // Fall through to the next configured server; regex remains the final fallback. } if (results.length >= limit) break; @@ -148,7 +149,7 @@ const CACHE_FILE = process.env.HAZE_REPO_MAP_CACHE interface RepoMapCacheEntry { mtime: number; - head: string; + head: string | null; symbols: RepoMapSymbol[]; } @@ -164,12 +165,13 @@ export interface RepoMapResult { source: 'lsp' | 'regex'; } -async function gitHead(): Promise { +async function gitHead(): Promise { try { const {stdout} = await execFile('git', ['-C', workspaceRoot(), 'rev-parse', 'HEAD']); return stdout.trim(); - } catch { - return ''; + } catch (error) { + console.error('Failed to read git HEAD for repo map cache:', error); + return null; } } @@ -177,7 +179,8 @@ async function recentlyTouchedFiles(maxCommits = 50): Promise> { try { const {stdout} = await execFile('git', ['-C', workspaceRoot(), 'log', `--max-count=${maxCommits}`, '--format=', '--name-only']); return new Set(stdout.split('\n').map(line => line.trim()).filter(Boolean)); - } catch { + } catch (error) { + console.warn('Failed to read recently touched files from git:', error); return new Set(); } } @@ -186,8 +189,12 @@ async function loadCache(): Promise> { try { const raw = await fs.readFile(CACHE_FILE, 'utf8'); return JSON.parse(raw) as Record; - } catch { - return {}; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + console.error('Repo map cache is corrupt or unreadable:', error); + throw error; } } @@ -266,11 +273,15 @@ export async function buildRepoMap(options: RepoMapOptions = {}): Promise ''); - const extracted = extractSymbolsFromSource(entry.path, content); - symbols.push(...extracted); - fileContents.set(entry.path, content); - cache[entry.path] = {mtime: stat.mtimeMs, head, symbols: extracted}; + try { + const content = await fs.readFile(entry.absolutePath, 'utf8'); + const extracted = extractSymbolsFromSource(entry.path, content); + symbols.push(...extracted); + fileContents.set(entry.path, content); + cache[entry.path] = {mtime: stat.mtimeMs, head, symbols: extracted}; + } catch (error) { + console.error(`Failed to read ${entry.path} for repo map:`, error); + } } await saveCache(cache); @@ -279,8 +290,12 @@ export async function buildRepoMap(options: RepoMapOptions = {}): Promise ''); - fileContents.set(symbol.path, content); + try { + const content = await fs.readFile(absolutePath, 'utf8'); + fileContents.set(symbol.path, content); + } catch (error) { + console.error(`Failed to read ${symbol.path} for repo map ranking:`, error); + } } const referenceCounts = await computeReferenceCounts(symbols, fileContents);