diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts index 7c771af0..be4235de 100644 --- a/__tests__/graph.test.ts +++ b/__tests__/graph.test.ts @@ -379,12 +379,15 @@ export { main }; const deps = cg.getFileDependencies('src/main.ts'); expect(Array.isArray(deps)).toBe(true); + expect(deps).toContain('src/derived.ts'); + expect(deps).toContain('src/utils.ts'); }); it('should get file dependents', () => { const dependents = cg.getFileDependents('src/utils.ts'); expect(Array.isArray(dependents)).toBe(true); + expect(dependents).toContain('src/main.ts'); }); }); diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index 53441d58..c35c0ea5 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -532,4 +532,45 @@ describe('Symlink Cycle Detection', () => { const files = scanDirectory(tempDir, config); expect(files).toContain('src/valid.ts'); }); + + it('should not index symlinked files that resolve outside the project root', async () => { + const outsideDir = createTempDir(); + let cg: CodeGraph | null = null; + + try { + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + + fs.writeFileSync( + path.join(outsideDir, 'secret.ts'), + 'export function leakedSecret() { return "nope"; }\n' + ); + + try { + fs.symlinkSync( + path.join(outsideDir, 'secret.ts'), + path.join(srcDir, 'secret.ts'), + 'file' + ); + } catch { + return; + } + + cg = CodeGraph.initSync(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + + expect(cg.searchNodes('leakedSecret')).toHaveLength(0); + expect(scanDirectory(tempDir, { + ...DEFAULT_CONFIG, + rootDir: tempDir, + include: ['**/*.ts'], + exclude: [], + })).not.toContain('src/secret.ts'); + } finally { + if (cg) cg.destroy(); + cleanupTempDir(outsideDir); + } + }); }); diff --git a/__tests__/sync.test.ts b/__tests__/sync.test.ts index 8365f630..ea237790 100644 --- a/__tests__/sync.test.ts +++ b/__tests__/sync.test.ts @@ -149,6 +149,46 @@ describe('Sync Module', () => { expect(result.filesRemoved).toBe(0); expect(result.filesChecked).toBeGreaterThan(0); }); + + it('should resolve references from unchanged files when a missing import target is added', async () => { + cg.destroy(); + fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true }); + + fs.writeFileSync( + path.join(testDir, 'src', 'caller.ts'), + `import { addedLater } from './added-later'; + +export function caller() { + return addedLater(); +} +` + ); + + cg = CodeGraph.initSync(testDir, { + config: { + include: ['**/*.ts'], + exclude: [], + }, + }); + await cg.indexAll(); + + const callerBefore = cg.searchNodes('caller', { kinds: ['function'], limit: 1 })[0]!.node; + expect(cg.getCallees(callerBefore.id)).toHaveLength(0); + + fs.writeFileSync( + path.join(testDir, 'src', 'added-later.ts'), + `export function addedLater() { + return 42; +} +` + ); + + const result = await cg.sync(); + expect(result.filesAdded).toBe(1); + + const callerAfter = cg.searchNodes('caller', { kinds: ['function'], limit: 1 })[0]!.node; + expect(cg.getCallees(callerAfter.id).map(({ node }) => node.name)).toContain('addedLater'); + }); }); }); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index bf1e6319..5810627d 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -20,7 +20,7 @@ import { QueryBuilder } from '../db/queries'; import { extractFromSource } from './tree-sitter'; import { detectLanguage, isLanguageSupported, initGrammars, loadGrammarsForLanguages } from './grammars'; import { logDebug, logWarn } from '../errors'; -import { validatePathWithinRoot, normalizePath } from '../utils'; +import { validatePathWithinRoot, normalizePath, isPathWithinRootReal } from '../utils'; import picomatch from 'picomatch'; import { detectFrameworks } from '../resolution/frameworks'; import type { ResolutionContext } from '../resolution/types'; @@ -353,6 +353,11 @@ function scanDirectoryWalk( if (entry.isSymbolicLink()) { try { + if (!isPathWithinRootReal(relativePath, rootDir)) { + logDebug('Skipping symlink that resolves outside project root', { path: fullPath }); + continue; + } + const realTarget = fs.realpathSync(fullPath); const stat = fs.statSync(realTarget); if (stat.isDirectory()) { diff --git a/src/index.ts b/src/index.ts index 7d586741..d8b25730 100644 --- a/src/index.ts +++ b/src/index.ts @@ -446,7 +446,25 @@ export class CodeGraph { // Resolve references if files were updated if (result.filesAdded > 0 || result.filesModified > 0) { - if (result.changedFilePaths) { + if (result.filesAdded > 0) { + // A newly added file can satisfy unresolved references from any + // already-indexed file, so re-check the unresolved table globally. + const unresolvedCount = this.queries.getUnresolvedReferencesCount(); + + options.onProgress?.({ + phase: 'resolving', + current: 0, + total: unresolvedCount, + }); + + await this.resolveReferencesBatched((current, total) => { + options.onProgress?.({ + phase: 'resolving', + current, + total, + }); + }); + } else if (result.changedFilePaths) { // Scope resolution to changed files (git fast path — bounded set) const unresolvedRefs = this.queries.getUnresolvedReferencesByFiles(result.changedFilePaths); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 34aa4b90..eccae173 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -17,7 +17,7 @@ import { ImportMapping, } from './types'; import { matchReference } from './name-matcher'; -import { resolveViaImport, extractImportMappings, extractReExports } from './import-resolver'; +import { resolveViaImport, extractImportMappings, extractReExports, resolveImportPath } from './import-resolver'; import { detectFrameworks } from './frameworks'; import { loadProjectAliases, type AliasMap } from './path-aliases'; import { logDebug } from '../errors'; @@ -453,6 +453,11 @@ export class ReferenceResolver { return null; } + const importFileResult = this.resolveImportToFile(ref); + if (importFileResult !== undefined) { + return importFileResult; + } + // Fast pre-filter: skip if no symbol with this name exists anywhere // AND the name doesn't match a local import. The import escape is // necessary because re-export rename chains (`import { login } @@ -495,6 +500,60 @@ export class ReferenceResolver { ); } + /** + * Resolve module import references to file nodes. Without this, module + * specifiers like "./utils" fall through to the name matcher and resolve to + * their own import node, which makes file dependency analysis useless. + * + * Returns undefined when the reference should continue through the normal + * strategies, or null when a local module import should remain unresolved + * until the target file appears. + */ + private resolveImportToFile(ref: UnresolvedRef): ResolvedRef | null | undefined { + if (ref.referenceKind !== 'imports') { + return undefined; + } + + const resolvedPath = resolveImportPath( + ref.referenceName, + ref.filePath, + ref.language, + this.context + ); + + if (resolvedPath) { + const fileNode = this.context + .getNodesInFile(resolvedPath) + .find((n) => n.kind === 'file'); + + if (fileNode) { + return { + original: ref, + targetNodeId: fileNode.id, + confidence: 0.95, + resolvedBy: 'file-path', + }; + } + } + + return this.isLocalImportSpecifier(ref.referenceName) ? null : undefined; + } + + private isLocalImportSpecifier(specifier: string): boolean { + if (specifier.startsWith('.')) return true; + + const aliases = this.context.getProjectAliases?.(); + if (aliases) { + for (const pattern of aliases.patterns) { + if (specifier.startsWith(pattern.prefix)) return true; + } + } + + return ['@/', '~/', '@src/', 'src/', '@app/', 'app/'].some((prefix) => + specifier.startsWith(prefix) + ); + } + /** * Create edges from resolved references */ @@ -590,10 +649,12 @@ export class ReferenceResolver { byMethod: {} as Record, }; - // Process in batches. We always read from offset 0 because resolved refs - // are deleted after each batch, shifting the remaining rows forward. + // Process in batches while preserving unresolved refs for future syncs. + // Added files can make a previously unresolved local import/call resolvable, + // so unresolved rows are intentionally left in the table. + let offset = 0; while (true) { - const batch = this.queries.getUnresolvedReferencesBatch(0, batchSize); + const batch = this.queries.getUnresolvedReferencesBatch(offset, batchSize); if (batch.length === 0) break; const result = this.resolveAll(batch); @@ -615,17 +676,6 @@ export class ReferenceResolver { ); } - // Delete unresolvable refs from this batch to avoid re-processing them - if (result.unresolved.length > 0) { - this.queries.deleteSpecificResolvedReferences( - result.unresolved.map((r) => ({ - fromNodeId: r.fromNodeId, - referenceName: r.referenceName, - referenceKind: r.referenceKind, - })) - ); - } - // Aggregate stats aggregateStats.total += result.stats.total; aggregateStats.resolved += result.stats.resolved; @@ -640,11 +690,9 @@ export class ReferenceResolver { // Yield so progress UI can render between batches await new Promise(resolve => setImmediate(resolve)); - // If nothing was resolved or removed in this batch, we'd loop forever - // on the same rows. Break to avoid infinite loop. - if (result.resolved.length === 0 && result.unresolved.length === batch.length) { - break; - } + // Resolved rows were deleted, so only the unresolved rows from this batch + // still occupy positions before the next unprocessed row. + offset += result.unresolved.length; } return { diff --git a/src/utils.ts b/src/utils.ts index e75e58e0..6094b5b1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -61,6 +61,19 @@ export function validatePathWithinRoot(projectRoot: string, filePath: string): s if (!resolved.startsWith(normalizedRoot + path.sep) && resolved !== normalizedRoot) { return null; } + + if (fs.existsSync(resolved)) { + try { + const realResolved = fs.realpathSync(resolved); + const realRoot = fs.realpathSync(normalizedRoot); + if (!realResolved.startsWith(realRoot + path.sep) && realResolved !== realRoot) { + return null; + } + } catch { + return null; + } + } + return resolved; }