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
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,11 @@
"default": true,
"description": "Exclude node_modules from graph"
},
"graph-it-live.ignoreTypeImports": {
"type": "boolean",
"default": false,
"description": "Ignore TypeScript type-only imports and exports in the dependency graph. You might need to restart VS Code for this setting to take effect."
},
"graph-it-live.enableMcpServer": {
"type": "boolean",
"default": false,
Expand Down
3 changes: 3 additions & 0 deletions src/analyzer/IndexerWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ interface WorkerConfig {
* Example: context.extensionPath from VS Code extension activation
*/
extensionPath?: string;
/** When true, type-only imports/exports are not treated as file dependencies. */
ignoreTypeImports?: boolean;
}

interface WorkerMessage {
Expand Down Expand Up @@ -218,6 +220,7 @@ async function runIndexing(config: WorkerConfig): Promise<void> {
config.rootDir,
config.tsConfigPath,
config.extensionPath,
config.ignoreTypeImports,
);

// Phase 1: Count files without loading paths into memory
Expand Down
1 change: 1 addition & 0 deletions src/analyzer/IndexerWorkerHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface WorkerConfig {
excludeNodeModules?: boolean;
tsConfigPath?: string;
extensionPath?: string;
ignoreTypeImports?: boolean;
/**
* Maximum ms to wait for indexing before forcefully terminating the worker.
* Protects against hangs due to WASM initialization failures or silent crashes.
Expand Down
12 changes: 7 additions & 5 deletions src/analyzer/LanguageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,20 @@ export class LanguageService {

private readonly rootDir?: string;
private readonly extensionPath?: string;
private readonly ignoreTypeImports?: boolean;

constructor(rootDir?: string, _tsConfigPath?: string, extensionPath?: string) {
constructor(rootDir?: string, _tsConfigPath?: string, extensionPath?: string, ignoreTypeImports?: boolean) {
this.rootDir = rootDir;
this.extensionPath = extensionPath;
this.ignoreTypeImports = ignoreTypeImports;
}

/**
* Instance method to get analyzer with instance-specific config
*/
getAnalyzer(filePath: string): ILanguageAnalyzer {
const actualPath = extractFilePath(filePath);
return LanguageService.getAnalyzer(actualPath, this.rootDir, this.extensionPath);
return LanguageService.getAnalyzer(actualPath, this.rootDir, this.extensionPath, this.ignoreTypeImports);
}

/**
Expand Down Expand Up @@ -111,18 +113,18 @@ export class LanguageService {
* Get the appropriate parser for the given file path.
* Lazy-loads parsers only when needed.
*/
static getAnalyzer(filePath: string, rootDir?: string, extensionPath?: string): ILanguageAnalyzer {
static getAnalyzer(filePath: string, rootDir?: string, extensionPath?: string, ignoreTypeImports?: boolean): ILanguageAnalyzer {
const actualPath = extractFilePath(filePath);
const language = this.detectLanguage(actualPath);

switch (language) {
case Language.TypeScript: {
const cacheKey = this.buildCacheKey(rootDir);
const cacheKey = this.buildCacheKey(rootDir, ignoreTypeImports ? "ignore-types" : "");
const cached = this.typeScriptParsers.get(cacheKey);
if (cached) {
return cached;
}
const created = new Parser(rootDir);
const created = new Parser(rootDir, ignoreTypeImports);
this.typeScriptParsers.set(cacheKey, created);
return created;
}
Expand Down
27 changes: 26 additions & 1 deletion src/analyzer/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ export class Parser implements ILanguageAnalyzer {
"g",
);
})();
constructor(rootDir?: string) {
private readonly ignoreTypeImports: boolean;
constructor(rootDir?: string, ignoreTypeImports?: boolean) {
this.fileReader = new FileReader();
this.pathResolver = new PathResolver(rootDir);
this.ignoreTypeImports = ignoreTypeImports ?? false;
}
// Regex patterns for different import types
private readonly patterns = {
Expand Down Expand Up @@ -175,6 +177,29 @@ export class Parser implements ILanguageAnalyzer {
continue;
}

// Skip type-only imports if configured to ignore them
if (this.ignoreTypeImports) {
const fullMatch = match[0].trim();

// `import type ...` / `export type ...`
if (/^(import|export)\s+type\b/.test(fullMatch)) {
continue;
}

// TS 5+ type-only named imports/exports: `import { type Foo } from '...'`
const braceMatch = fullMatch.match(/^(import|export)\s+\{([\s\S]*?)\}\s+from\b/);
if (braceMatch) {
const specifiers = braceMatch[2]
.split(',')
.map((s) => s.trim())
.filter(Boolean);

if (specifiers.length > 0 && specifiers.every((s) => /^type\b/.test(s))) {
continue;
}
}
}

seen.add(module);

// Find line number
Expand Down
18 changes: 17 additions & 1 deletion src/analyzer/SpiderBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export class SpiderBuilder {
private maxSymbolCacheSize: number = 200;
private maxSymbolAnalyzerFiles: number = 100;
private indexingProgressInterval?: number;
private ignoreTypeImports: boolean = false;

// Service overrides (for testing)
private customCache?: Cache<Dependency[]>;
Expand Down Expand Up @@ -393,6 +394,17 @@ export class SpiderBuilder {
return this;
}

/**
* Set whether to ignore type-only imports and exports when parsing dependencies (default: false).
*
* @param ignore - Whether to ignore type-only imports/exports
* @returns This builder instance for method chaining
*/
withIgnoreTypeImports(ignore: boolean): this {
this.ignoreTypeImports = ignore;
return this;
}

/**
* Enable or disable reverse index (default: false).
*
Expand Down Expand Up @@ -559,6 +571,9 @@ export class SpiderBuilder {
if (config.excludeNodeModules !== undefined) {
this.excludeNodeModules = config.excludeNodeModules;
}
if (config.ignoreTypeImports !== undefined) {
this.ignoreTypeImports = config.ignoreTypeImports;
}
if (config.enableReverseIndex !== undefined) {
this.enableReverseIndex = config.enableReverseIndex;
}
Expand Down Expand Up @@ -781,7 +796,7 @@ export class SpiderBuilder {

// Phase 1: Core services (no dependencies)
const languageService = this.customLanguageService ??
new LanguageService(this.rootDir!, this.tsConfigPath, this.extensionPath);
new LanguageService(this.rootDir!, this.tsConfigPath, this.extensionPath, this.ignoreTypeImports);

const resolver = this.customPathResolver ??
new PathResolver(this.tsConfigPath, this.excludeNodeModules, this.rootDir!);
Expand Down Expand Up @@ -946,6 +961,7 @@ export class SpiderBuilder {
extensionPath: this.extensionPath,
maxDepth: this.maxDepth,
excludeNodeModules: this.excludeNodeModules,
ignoreTypeImports: this.ignoreTypeImports,
enableReverseIndex: this.enableReverseIndex,
indexingConcurrency: this.indexingConcurrency,
maxCacheSize: this.maxCacheSize,
Expand Down
1 change: 1 addition & 0 deletions src/analyzer/spider/SpiderIndexingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export class SpiderIndexingService {
tsConfigPath: config.tsConfigPath,
progressInterval: config.indexingProgressInterval,
extensionPath: config.extensionPath,
ignoreTypeImports: config.ignoreTypeImports,
},
});
}
Expand Down
2 changes: 2 additions & 0 deletions src/analyzer/spider/SpiderWorkerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface WorkerBuildOptions {
tsConfigPath?: string;
progressInterval?: number;
extensionPath?: string;
ignoreTypeImports?: boolean;
};
progressCallback?: IndexingProgressCallback;
}
Expand Down Expand Up @@ -74,6 +75,7 @@ export class SpiderWorkerManager {
excludeNodeModules: config.excludeNodeModules,
tsConfigPath: config.tsConfigPath,
extensionPath: config.extensionPath,
ignoreTypeImports: config.ignoreTypeImports,
});

this.importWorkerResult(result);
Expand Down
2 changes: 2 additions & 0 deletions src/analyzer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ export interface SpiderConfig {
extensionPath?: string;
maxDepth?: number;
excludeNodeModules?: boolean;
/** Ignore type-only imports/exports on TypeScript files */
ignoreTypeImports?: boolean;
/** Interval for worker progress reporting (defaults to 100 files) */
indexingProgressInterval?: number;
/** Enable reverse index for O(1) reverse dependency lookups */
Expand Down
1 change: 0 additions & 1 deletion src/analyzer/wiki/WikiGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ export class WikiGenerator {
private readonly externalNodeMetadata: Record<string, GraphNodeMetadata> | undefined;

constructor(opts: WikiGeneratorOptions) {

this.db = opts.db;
this.outputDir = opts.outputDir;
this.workspaceRoot = opts.workspaceRoot;
Expand Down
2 changes: 2 additions & 0 deletions src/extension/services/ProviderStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ProviderConfigSnapshot extends BackgroundIndexingConfig {
excludeNodeModules: boolean;
maxDepth: number;
indexingConcurrency: number;
ignoreTypeImports: boolean;
unusedDependencyMode: UnusedDependencyMode;
unusedAnalysisConcurrency: number;
unusedAnalysisMaxEdges: number;
Expand Down Expand Up @@ -56,6 +57,7 @@ export class ProviderStateManager {
: profileDefaults.indexingConcurrency,
indexingStartDelay: config.get<number>('indexingStartDelay', this.defaultIndexingDelay),
persistIndex: config.get<boolean>('persistIndex', false),
ignoreTypeImports: config.get<boolean>('ignoreTypeImports', false),
unusedDependencyMode: config.get<'none' | 'hide' | 'dim'>('unusedDependencyMode', 'none'),
unusedAnalysisConcurrency: isCustomProfile
? config.get<number>('unusedAnalysisConcurrency', profileDefaults.unusedAnalysisConcurrency)
Expand Down
1 change: 1 addition & 0 deletions src/extension/services/graphProviderServiceContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export function createGraphProviderServiceContainer(
.withTsConfigPath(path.join(workspaceRoot, "tsconfig.json"))
.withExtensionPath(options.context.extensionPath)
.withExcludeNodeModules(configSnapshot.excludeNodeModules)
.withIgnoreTypeImports(configSnapshot.ignoreTypeImports)
.withMaxDepth(configSnapshot.maxDepth)
.withReverseIndex(configSnapshot.enableBackgroundIndexing)
.withIndexingConcurrency(configSnapshot.indexingConcurrency)
Expand Down
19 changes: 19 additions & 0 deletions tests/analyzer/LanguageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,25 @@ describe("LanguageService", () => {
expect(analyzer1).toBe(analyzer2);
});

it("should use a separate TypeScript parser when ignoreTypeImports changes", () => {
const analyzer1 = LanguageService.getAnalyzer("/project/file1.ts");
const analyzer2 = LanguageService.getAnalyzer(
"/project/file2.ts",
undefined,
undefined,
true,
);
const analyzer3 = LanguageService.getAnalyzer(
"/project/file3.ts",
undefined,
undefined,
true,
);

expect(analyzer1).not.toBe(analyzer2);
expect(analyzer2).toBe(analyzer3);
});

it("should throw error for Python files (not yet implemented)", () => {
const analyzer = LanguageService.getAnalyzer("/project/main.py");
expect(analyzer).toBeDefined();
Expand Down
13 changes: 8 additions & 5 deletions tests/analyzer/SpiderBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ describe('SpiderBuilder', () => {
const result1 = builder.withRootDir(fixturesPath);
const result2 = result1.withMaxDepth(50);
const result3 = result2.withExcludeNodeModules(true);
const result4 = result3.withReverseIndex(false);
const result5 = result4.withIndexingConcurrency(4);
const result6 = result5.withCacheConfig({ maxCacheSize: 500 });
const result7 = result6.withTsConfigPath(path.join(fixturesPath, 'tsconfig.json'));
const result8 = result7.withIndexingProgressInterval(100);
const result4 = result3.withIgnoreTypeImports(true);
const result5 = result4.withReverseIndex(false);
const result6 = result5.withIndexingConcurrency(4);
const result7 = result6.withCacheConfig({ maxCacheSize: 500 });
const result8 = result7.withTsConfigPath(path.join(fixturesPath, 'tsconfig.json'));
const result9 = result8.withIndexingProgressInterval(100);

// All methods should return the same builder instance
expect(result1).toBe(builder);
Expand All @@ -33,6 +34,7 @@ describe('SpiderBuilder', () => {
expect(result6).toBe(builder);
expect(result7).toBe(builder);
expect(result8).toBe(builder);
expect(result9).toBe(builder);
});

it('should support method chaining in a single expression', () => {
Expand Down Expand Up @@ -277,6 +279,7 @@ describe('SpiderBuilder', () => {
tsConfigPath: path.join(fixturesPath, 'tsconfig.json'),
maxDepth: 30,
excludeNodeModules: false,
ignoreTypeImports: true,
enableReverseIndex: true,
indexingConcurrency: 8,
maxCacheSize: 1000,
Expand Down
75 changes: 75 additions & 0 deletions tests/analyzer/parser-type-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { Parser } from '../../src/analyzer/Parser';

describe('Parser - Type-Only Imports', () => {
it('should skip type-only imports and exports when configured', () => {
const parser = new Parser(undefined, true);

const content = `
import type { Foo } from './foo';
import { Bar } from './bar';
export type { Baz } from './baz';
export { Quux } from './quux';
`;

const imports = parser.parse(content, '/project/file.ts');

expect(imports).toHaveLength(2);
expect(imports.map((entry) => entry.module)).toEqual(['./bar', './quux']);
});

it('should keep type-only imports and exports when not configured to ignore them', () => {
const parser = new Parser();

const content = `
import type { Foo } from './foo';
import { Bar } from './bar';
export type { Baz } from './baz';
export { Quux } from './quux';
`;

const imports = parser.parse(content, '/project/file.ts');

expect(imports).toHaveLength(4);
expect(imports.map((entry) => entry.module)).toEqual([
'./foo',
'./bar',
'./baz',
'./quux',
]);
});

it('should skip TS 5+ per-specifier type-only imports when configured', () => {
const parser = new Parser(undefined, true);

const content = `
import { type Foo } from './foo';
import { type Bar, type Baz } from './types';
import { Quux } from './quux';
export { type Corge } from './corge';
`;

const imports = parser.parse(content, '/project/file.ts');

expect(imports).toHaveLength(1);
expect(imports.map((entry) => entry.module)).toEqual(['./quux']);
});

it('should NOT skip mixed imports (type + value specifiers) when configured', () => {
const parser = new Parser(undefined, true);

const content = `
import { type Foo, Bar } from './mixed';
import { type Baz, type Qux } from './types-only';
import { Value } from './value';
`;

const imports = parser.parse(content, '/project/file.ts');

// './mixed' has a value specifier (Bar) → must be kept
// './types-only' has only type specifiers → skipped
// './value' is a value import → kept
expect(imports).toHaveLength(2);
expect(imports.map((entry) => entry.module)).toEqual(['./mixed', './value']);
});
});
2 changes: 2 additions & 0 deletions tests/extension/ProviderStateManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const configValues: Record<string, unknown> = {
indexingConcurrency: 8,
indexingStartDelay: 2000,
persistIndex: true,
ignoreTypeImports: true,
};

vi.mock('vscode', () => ({
Expand Down Expand Up @@ -51,6 +52,7 @@ describe('ProviderStateManager', () => {
expect(snapshot.maxDepth).toBe(25);
expect(snapshot.enableBackgroundIndexing).toBe(true);
expect(snapshot.indexingStartDelay).toBe(2000);
expect(snapshot.ignoreTypeImports).toBe(true);
});

it('tracks current symbol path', () => {
Expand Down
Loading