diff --git a/package.json b/package.json index 74384f4..1f11ffd 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/src/analyzer/IndexerWorker.ts b/src/analyzer/IndexerWorker.ts index 76452a6..4c5247d 100644 --- a/src/analyzer/IndexerWorker.ts +++ b/src/analyzer/IndexerWorker.ts @@ -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 { @@ -218,6 +220,7 @@ async function runIndexing(config: WorkerConfig): Promise { config.rootDir, config.tsConfigPath, config.extensionPath, + config.ignoreTypeImports, ); // Phase 1: Count files without loading paths into memory diff --git a/src/analyzer/IndexerWorkerHost.ts b/src/analyzer/IndexerWorkerHost.ts index 4b60846..3fd652b 100644 --- a/src/analyzer/IndexerWorkerHost.ts +++ b/src/analyzer/IndexerWorkerHost.ts @@ -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. diff --git a/src/analyzer/LanguageService.ts b/src/analyzer/LanguageService.ts index bd47d75..c123cbf 100644 --- a/src/analyzer/LanguageService.ts +++ b/src/analyzer/LanguageService.ts @@ -41,10 +41,12 @@ 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; } /** @@ -52,7 +54,7 @@ export class LanguageService { */ 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); } /** @@ -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; } diff --git a/src/analyzer/Parser.ts b/src/analyzer/Parser.ts index 612b824..b1c81ed 100644 --- a/src/analyzer/Parser.ts +++ b/src/analyzer/Parser.ts @@ -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 = { @@ -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 diff --git a/src/analyzer/SpiderBuilder.ts b/src/analyzer/SpiderBuilder.ts index 057797d..303faa0 100644 --- a/src/analyzer/SpiderBuilder.ts +++ b/src/analyzer/SpiderBuilder.ts @@ -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; @@ -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). * @@ -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; } @@ -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!); @@ -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, diff --git a/src/analyzer/spider/SpiderIndexingService.ts b/src/analyzer/spider/SpiderIndexingService.ts index 3fe8ebe..e05d131 100644 --- a/src/analyzer/spider/SpiderIndexingService.ts +++ b/src/analyzer/spider/SpiderIndexingService.ts @@ -155,6 +155,7 @@ export class SpiderIndexingService { tsConfigPath: config.tsConfigPath, progressInterval: config.indexingProgressInterval, extensionPath: config.extensionPath, + ignoreTypeImports: config.ignoreTypeImports, }, }); } diff --git a/src/analyzer/spider/SpiderWorkerManager.ts b/src/analyzer/spider/SpiderWorkerManager.ts index 23c64c4..fdb7e1f 100644 --- a/src/analyzer/spider/SpiderWorkerManager.ts +++ b/src/analyzer/spider/SpiderWorkerManager.ts @@ -12,6 +12,7 @@ interface WorkerBuildOptions { tsConfigPath?: string; progressInterval?: number; extensionPath?: string; + ignoreTypeImports?: boolean; }; progressCallback?: IndexingProgressCallback; } @@ -74,6 +75,7 @@ export class SpiderWorkerManager { excludeNodeModules: config.excludeNodeModules, tsConfigPath: config.tsConfigPath, extensionPath: config.extensionPath, + ignoreTypeImports: config.ignoreTypeImports, }); this.importWorkerResult(result); diff --git a/src/analyzer/types.ts b/src/analyzer/types.ts index bde5651..80e1e1c 100644 --- a/src/analyzer/types.ts +++ b/src/analyzer/types.ts @@ -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 */ diff --git a/src/analyzer/wiki/WikiGenerator.ts b/src/analyzer/wiki/WikiGenerator.ts index 8823bf2..78b7297 100644 --- a/src/analyzer/wiki/WikiGenerator.ts +++ b/src/analyzer/wiki/WikiGenerator.ts @@ -140,7 +140,6 @@ export class WikiGenerator { private readonly externalNodeMetadata: Record | undefined; constructor(opts: WikiGeneratorOptions) { - this.db = opts.db; this.outputDir = opts.outputDir; this.workspaceRoot = opts.workspaceRoot; diff --git a/src/extension/services/ProviderStateManager.ts b/src/extension/services/ProviderStateManager.ts index 0b23a93..19a3394 100644 --- a/src/extension/services/ProviderStateManager.ts +++ b/src/extension/services/ProviderStateManager.ts @@ -9,6 +9,7 @@ export interface ProviderConfigSnapshot extends BackgroundIndexingConfig { excludeNodeModules: boolean; maxDepth: number; indexingConcurrency: number; + ignoreTypeImports: boolean; unusedDependencyMode: UnusedDependencyMode; unusedAnalysisConcurrency: number; unusedAnalysisMaxEdges: number; @@ -56,6 +57,7 @@ export class ProviderStateManager { : profileDefaults.indexingConcurrency, indexingStartDelay: config.get('indexingStartDelay', this.defaultIndexingDelay), persistIndex: config.get('persistIndex', false), + ignoreTypeImports: config.get('ignoreTypeImports', false), unusedDependencyMode: config.get<'none' | 'hide' | 'dim'>('unusedDependencyMode', 'none'), unusedAnalysisConcurrency: isCustomProfile ? config.get('unusedAnalysisConcurrency', profileDefaults.unusedAnalysisConcurrency) diff --git a/src/extension/services/graphProviderServiceContainer.ts b/src/extension/services/graphProviderServiceContainer.ts index 9178c7b..d514a67 100644 --- a/src/extension/services/graphProviderServiceContainer.ts +++ b/src/extension/services/graphProviderServiceContainer.ts @@ -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) diff --git a/tests/analyzer/LanguageService.test.ts b/tests/analyzer/LanguageService.test.ts index cbc8a97..aca88ae 100644 --- a/tests/analyzer/LanguageService.test.ts +++ b/tests/analyzer/LanguageService.test.ts @@ -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(); diff --git a/tests/analyzer/SpiderBuilder.test.ts b/tests/analyzer/SpiderBuilder.test.ts index f247427..5bbe28e 100644 --- a/tests/analyzer/SpiderBuilder.test.ts +++ b/tests/analyzer/SpiderBuilder.test.ts @@ -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); @@ -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', () => { @@ -277,6 +279,7 @@ describe('SpiderBuilder', () => { tsConfigPath: path.join(fixturesPath, 'tsconfig.json'), maxDepth: 30, excludeNodeModules: false, + ignoreTypeImports: true, enableReverseIndex: true, indexingConcurrency: 8, maxCacheSize: 1000, diff --git a/tests/analyzer/parser-type-imports.test.ts b/tests/analyzer/parser-type-imports.test.ts new file mode 100644 index 0000000..2338ed3 --- /dev/null +++ b/tests/analyzer/parser-type-imports.test.ts @@ -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']); + }); +}); \ No newline at end of file diff --git a/tests/extension/ProviderStateManager.test.ts b/tests/extension/ProviderStateManager.test.ts index c13ff59..d73b969 100644 --- a/tests/extension/ProviderStateManager.test.ts +++ b/tests/extension/ProviderStateManager.test.ts @@ -8,6 +8,7 @@ const configValues: Record = { indexingConcurrency: 8, indexingStartDelay: 2000, persistIndex: true, + ignoreTypeImports: true, }; vi.mock('vscode', () => ({ @@ -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', () => {