Skip to content
Open
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
3 changes: 3 additions & 0 deletions __tests__/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down
41 changes: 41 additions & 0 deletions __tests__/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
40 changes: 40 additions & 0 deletions __tests__/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});

Expand Down
7 changes: 6 additions & 1 deletion src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()) {
Expand Down
20 changes: 19 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
88 changes: 68 additions & 20 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -590,10 +649,12 @@ export class ReferenceResolver {
byMethod: {} as Record<string, number>,
};

// 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);
Expand All @@ -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;
Expand All @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down