@grafema/core 0.2.10 → 0.2.12-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/Orchestrator.d.ts +13 -0
  2. package/dist/Orchestrator.d.ts.map +1 -1
  3. package/dist/Orchestrator.js +84 -2
  4. package/dist/Orchestrator.js.map +1 -1
  5. package/dist/ParallelAnalysisRunner.d.ts.map +1 -1
  6. package/dist/ParallelAnalysisRunner.js +28 -41
  7. package/dist/ParallelAnalysisRunner.js.map +1 -1
  8. package/dist/PhaseRunner.d.ts +2 -0
  9. package/dist/PhaseRunner.d.ts.map +1 -1
  10. package/dist/PhaseRunner.js +8 -3
  11. package/dist/PhaseRunner.js.map +1 -1
  12. package/dist/core/IncrementalReanalyzer.d.ts +3 -3
  13. package/dist/core/IncrementalReanalyzer.d.ts.map +1 -1
  14. package/dist/core/IncrementalReanalyzer.js +12 -12
  15. package/dist/core/IncrementalReanalyzer.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/plugins/analysis/JSASTAnalyzer.d.ts.map +1 -1
  21. package/dist/plugins/analysis/JSASTAnalyzer.js +35 -3
  22. package/dist/plugins/analysis/JSASTAnalyzer.js.map +1 -1
  23. package/dist/plugins/analysis/ast/GraphBuilder.d.ts.map +1 -1
  24. package/dist/plugins/analysis/ast/GraphBuilder.js +8 -0
  25. package/dist/plugins/analysis/ast/GraphBuilder.js.map +1 -1
  26. package/dist/plugins/analysis/ast/builders/ModuleRuntimeBuilder.d.ts.map +1 -1
  27. package/dist/plugins/analysis/ast/builders/ModuleRuntimeBuilder.js +4 -2
  28. package/dist/plugins/analysis/ast/builders/ModuleRuntimeBuilder.js.map +1 -1
  29. package/dist/plugins/analysis/ast/handlers/NewExpressionHandler.d.ts.map +1 -1
  30. package/dist/plugins/analysis/ast/handlers/NewExpressionHandler.js +2 -1
  31. package/dist/plugins/analysis/ast/handlers/NewExpressionHandler.js.map +1 -1
  32. package/dist/plugins/analysis/ast/types.d.ts +3 -0
  33. package/dist/plugins/analysis/ast/types.d.ts.map +1 -1
  34. package/dist/plugins/analysis/ast/visitors/ImportExportVisitor.d.ts.map +1 -1
  35. package/dist/plugins/analysis/ast/visitors/ImportExportVisitor.js +6 -2
  36. package/dist/plugins/analysis/ast/visitors/ImportExportVisitor.js.map +1 -1
  37. package/dist/plugins/enrichment/ImportExportLinker.d.ts.map +1 -1
  38. package/dist/plugins/enrichment/ImportExportLinker.js +15 -5
  39. package/dist/plugins/enrichment/ImportExportLinker.js.map +1 -1
  40. package/dist/storage/backends/RFDBServerBackend.d.ts +21 -7
  41. package/dist/storage/backends/RFDBServerBackend.d.ts.map +1 -1
  42. package/dist/storage/backends/RFDBServerBackend.js +48 -48
  43. package/dist/storage/backends/RFDBServerBackend.js.map +1 -1
  44. package/dist/utils/startRfdbServer.d.ts +44 -0
  45. package/dist/utils/startRfdbServer.d.ts.map +1 -0
  46. package/dist/utils/startRfdbServer.js +79 -0
  47. package/dist/utils/startRfdbServer.js.map +1 -0
  48. package/package.json +3 -3
  49. package/src/Orchestrator.ts +91 -2
  50. package/src/ParallelAnalysisRunner.ts +30 -48
  51. package/src/PhaseRunner.ts +10 -3
  52. package/src/core/IncrementalReanalyzer.ts +15 -15
  53. package/src/index.ts +4 -0
  54. package/src/plugins/analysis/JSASTAnalyzer.ts +40 -3
  55. package/src/plugins/analysis/ast/GraphBuilder.ts +9 -0
  56. package/src/plugins/analysis/ast/builders/ModuleRuntimeBuilder.ts +4 -2
  57. package/src/plugins/analysis/ast/handlers/NewExpressionHandler.ts +2 -1
  58. package/src/plugins/analysis/ast/types.ts +3 -0
  59. package/src/plugins/analysis/ast/visitors/ImportExportVisitor.ts +8 -2
  60. package/src/plugins/enrichment/ImportExportLinker.ts +15 -5
  61. package/src/storage/backends/RFDBServerBackend.ts +52 -60
  62. package/src/utils/startRfdbServer.ts +126 -0
@@ -9,17 +9,17 @@
9
9
  * - Barrier: wait for all tasks before returning
10
10
  */
11
11
 
12
- import { existsSync, unlinkSync } from 'fs';
12
+ import { existsSync } from 'fs';
13
13
  import { join, dirname } from 'path';
14
- import { fileURLToPath } from 'url';
15
14
  import type { ChildProcess } from 'child_process';
16
- import { spawn, execSync } from 'child_process';
17
15
  import { setTimeout as sleep } from 'timers/promises';
18
16
  import { AnalysisQueue } from './core/AnalysisQueue.js';
19
17
  import type { Plugin } from './plugins/Plugin.js';
20
18
  import type { GraphBackend, Logger } from '@grafema/types';
21
19
  import type { ProgressCallback } from './PhaseRunner.js';
22
20
  import type { ParallelConfig, DiscoveryManifest } from './OrchestratorTypes.js';
21
+ import { findRfdbBinary } from './utils/findRfdbBinary.js';
22
+ import { startRfdbServer } from './utils/startRfdbServer.js';
23
23
 
24
24
  export class ParallelAnalysisRunner {
25
25
  private analysisQueue: AnalysisQueue | null = null;
@@ -43,10 +43,9 @@ export class ParallelAnalysisRunner {
43
43
  * - Barrier waits for all tasks before ENRICHMENT phase
44
44
  */
45
45
  async run(manifest: DiscoveryManifest): Promise<void> {
46
- const socketPath = this.parallelConfig.socketPath || '/tmp/rfdb.sock';
47
- const maxWorkers = this.parallelConfig.maxWorkers || null;
48
-
49
46
  const mainDbPath = (this.graph as unknown as { dbPath?: string }).dbPath || join(manifest.projectPath, '.grafema', 'graph.rfdb');
47
+ const socketPath = this.parallelConfig.socketPath || join(dirname(mainDbPath), 'rfdb.sock');
48
+ const maxWorkers = this.parallelConfig.maxWorkers || null;
50
49
 
51
50
  this.logger.debug('Starting queue-based parallel analysis', { database: mainDbPath });
52
51
 
@@ -67,6 +66,20 @@ export class ParallelAnalysisRunner {
67
66
  await this.analysisQueue.start();
68
67
 
69
68
  let moduleCount = 0;
69
+ let completedCount = 0;
70
+
71
+ this.analysisQueue.on('taskCompleted', ({ file, stats, duration }: { file: string; stats?: { nodes?: number }; duration: number }) => {
72
+ completedCount++;
73
+ this.onProgress({
74
+ phase: 'analysis',
75
+ currentPlugin: 'AnalysisQueue',
76
+ message: `${file.split('/').pop()} (${stats?.nodes || 0} nodes, ${duration}ms)`,
77
+ totalFiles: moduleCount,
78
+ processedFiles: completedCount,
79
+ currentService: file,
80
+ });
81
+ });
82
+
70
83
  for await (const node of this.graph.queryNodes({ type: 'MODULE' })) {
71
84
  if (!node.file?.match(/\.(js|jsx|ts|tsx|mjs|cjs)$/)) continue;
72
85
 
@@ -81,14 +94,6 @@ export class ParallelAnalysisRunner {
81
94
 
82
95
  this.logger.debug('Queued modules for analysis', { count: moduleCount });
83
96
 
84
- this.analysisQueue.on('taskCompleted', ({ file, stats, duration }: { file: string; stats?: { nodes?: number }; duration: number }) => {
85
- this.onProgress({
86
- phase: 'analysis',
87
- currentPlugin: 'AnalysisQueue',
88
- message: `${file.split('/').pop()} (${stats?.nodes || 0} nodes, ${duration}ms)`,
89
- });
90
- });
91
-
92
97
  this.analysisQueue.on('taskFailed', ({ file, error }: { file: string; error: string }) => {
93
98
  this.logger.error('Analysis failed', { file, error });
94
99
  });
@@ -111,6 +116,7 @@ export class ParallelAnalysisRunner {
111
116
  * Start RFDB server process (or connect to existing one).
112
117
  */
113
118
  private async startRfdbServer(socketPath: string, dbPath: string): Promise<void> {
119
+ // Check if server is already running
114
120
  if (existsSync(socketPath)) {
115
121
  try {
116
122
  const { RFDBClient } = await import('@grafema/rfdb-client');
@@ -123,49 +129,25 @@ export class ParallelAnalysisRunner {
123
129
  this._serverWasExternal = true;
124
130
  return;
125
131
  } catch {
126
- this.logger.debug('Stale socket found, removing');
127
- unlinkSync(socketPath);
132
+ this.logger.debug('Stale socket found, will be removed by startRfdbServer');
128
133
  }
129
134
  }
130
135
 
131
- const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '../..');
132
- const serverBinary = join(projectRoot, 'packages/rfdb-server/target/release/rfdb-server');
133
- const debugBinary = join(projectRoot, 'packages/rfdb-server/target/debug/rfdb-server');
134
-
135
- let binaryPath = existsSync(serverBinary) ? serverBinary : debugBinary;
136
-
137
- if (!existsSync(binaryPath)) {
138
- this.logger.debug('RFDB server binary not found, building', { path: binaryPath });
139
- execSync('cargo build --bin rfdb-server', {
140
- cwd: join(projectRoot, 'packages/rfdb-server'),
141
- stdio: 'inherit',
142
- });
143
- binaryPath = debugBinary;
136
+ const binaryPath = findRfdbBinary();
137
+ if (!binaryPath) {
138
+ throw new Error('RFDB server binary not found');
144
139
  }
145
140
 
146
141
  this.logger.debug('Starting RFDB server', { binary: binaryPath, database: dbPath });
147
- this.rfdbServerProcess = spawn(binaryPath, [dbPath, '--socket', socketPath], {
148
- stdio: ['ignore', 'pipe', 'pipe'],
142
+ this.rfdbServerProcess = await startRfdbServer({
143
+ dbPath,
144
+ socketPath,
145
+ binaryPath,
146
+ waitTimeoutMs: 3000,
147
+ logger: { debug: (m: string) => this.logger.debug(m) },
149
148
  });
150
149
  this._serverWasExternal = false;
151
150
 
152
- this.rfdbServerProcess.stderr?.on('data', (data: Buffer) => {
153
- const msg = data.toString().trim();
154
- if (!msg.includes('FLUSH') && !msg.includes('WRITER')) {
155
- this.logger.debug('rfdb-server', { message: msg });
156
- }
157
- });
158
-
159
- let attempts = 0;
160
- while (!existsSync(socketPath) && attempts < 30) {
161
- await sleep(100);
162
- attempts++;
163
- }
164
-
165
- if (!existsSync(socketPath)) {
166
- throw new Error('RFDB server failed to start');
167
- }
168
-
169
151
  this.logger.debug('RFDB server started', { socketPath });
170
152
  }
171
153
 
@@ -50,6 +50,8 @@ export interface PhaseRunnerDeps {
50
50
  resourceRegistry: ResourceRegistry;
51
51
  configServices?: ServiceDefinition[];
52
52
  routing?: RoutingRule[];
53
+ /** When true, commitBatch defers index rebuild for bulk load (REG-487). */
54
+ deferIndexing?: boolean;
53
55
  }
54
56
 
55
57
  export class PhaseRunner {
@@ -77,8 +79,9 @@ export class PhaseRunner {
77
79
  ): Promise<{ result: PluginResult; delta: CommitDelta | null }> {
78
80
  const graph = pluginContext.graph;
79
81
 
80
- // Fallback: backend doesn't support batching
81
- if (!graph.beginBatch || !graph.commitBatch || !graph.abortBatch) {
82
+ // Fallback: backend doesn't support batching, or plugin manages its own batches
83
+ if (!graph.beginBatch || !graph.commitBatch || !graph.abortBatch
84
+ || plugin.metadata.managesBatch) {
82
85
  const result = await plugin.execute(pluginContext);
83
86
  return { result, delta: null };
84
87
  }
@@ -91,7 +94,9 @@ export class PhaseRunner {
91
94
  graph.beginBatch();
92
95
  try {
93
96
  const result = await plugin.execute(pluginContext);
94
- const delta = await graph.commitBatch(tags);
97
+ const deferIndex = pluginContext.deferIndexing ?? false;
98
+ const protectedTypes = phaseName === 'ANALYSIS' ? ['MODULE'] : undefined;
99
+ const delta = await graph.commitBatch(tags, deferIndex, protectedTypes);
95
100
  return { result, delta };
96
101
  } catch (error) {
97
102
  graph.abortBatch();
@@ -120,6 +125,8 @@ export class PhaseRunner {
120
125
  rootPrefix: (baseContext as { rootPrefix?: string }).rootPrefix,
121
126
  // REG-256: Pass resource registry for inter-plugin communication
122
127
  resources: resourceRegistry,
128
+ // REG-487: Pass deferIndexing flag for bulk load optimization
129
+ deferIndexing: this.deps.deferIndexing ?? false,
123
130
  };
124
131
 
125
132
  // REG-256: Ensure config is available with routing and services for all plugins
@@ -25,9 +25,9 @@ export interface ReanalysisOptions {
25
25
 
26
26
  export interface ReanalysisProgress {
27
27
  phase: 'clearing' | 'indexing' | 'analysis' | 'enrichment';
28
- current: number;
29
- total: number;
30
- currentFile?: string;
28
+ processedFiles: number;
29
+ totalFiles: number;
30
+ currentService?: string;
31
31
  }
32
32
 
33
33
  export interface ReanalysisResult {
@@ -78,9 +78,9 @@ export class IncrementalReanalyzer {
78
78
  if (options.onProgress) {
79
79
  options.onProgress({
80
80
  phase: 'clearing',
81
- current: i + 1,
82
- total: staleModules.length,
83
- currentFile: module.file
81
+ processedFiles: i + 1,
82
+ totalFiles: staleModules.length,
83
+ currentService: module.file
84
84
  });
85
85
  }
86
86
  const cleared = await clearFileNodesIfNeeded(this.graph, module.file, touchedFiles);
@@ -98,9 +98,9 @@ export class IncrementalReanalyzer {
98
98
  if (options.onProgress) {
99
99
  options.onProgress({
100
100
  phase: 'indexing',
101
- current: i + 1,
102
- total: modifiedModules.length,
103
- currentFile: module.file
101
+ processedFiles: i + 1,
102
+ totalFiles: modifiedModules.length,
103
+ currentService: module.file
104
104
  });
105
105
  }
106
106
 
@@ -127,9 +127,9 @@ export class IncrementalReanalyzer {
127
127
  if (options.onProgress) {
128
128
  options.onProgress({
129
129
  phase: 'analysis',
130
- current: i + 1,
131
- total: modulesToAnalyze.length,
132
- currentFile: module.file
130
+ processedFiles: i + 1,
131
+ totalFiles: modulesToAnalyze.length,
132
+ currentService: module.file
133
133
  });
134
134
  }
135
135
 
@@ -150,7 +150,7 @@ export class IncrementalReanalyzer {
150
150
  // STEP 4: Re-run enrichment plugins
151
151
  if (!options.skipEnrichment && modulesToAnalyze.length > 0) {
152
152
  if (options.onProgress) {
153
- options.onProgress({ phase: 'enrichment', current: 0, total: 2 });
153
+ options.onProgress({ phase: 'enrichment', processedFiles: 0, totalFiles: 2 });
154
154
  }
155
155
 
156
156
  const pluginContext: PluginContext = {
@@ -169,7 +169,7 @@ export class IncrementalReanalyzer {
169
169
  }
170
170
 
171
171
  if (options.onProgress) {
172
- options.onProgress({ phase: 'enrichment', current: 1, total: 2 });
172
+ options.onProgress({ phase: 'enrichment', processedFiles: 1, totalFiles: 2 });
173
173
  }
174
174
 
175
175
  const importExportLinker = new ImportExportLinker();
@@ -182,7 +182,7 @@ export class IncrementalReanalyzer {
182
182
  }
183
183
 
184
184
  if (options.onProgress) {
185
- options.onProgress({ phase: 'enrichment', current: 2, total: 2 });
185
+ options.onProgress({ phase: 'enrichment', processedFiles: 2, totalFiles: 2 });
186
186
  }
187
187
  }
188
188
 
package/src/index.ts CHANGED
@@ -129,6 +129,10 @@ export { calculateFileHash, calculateFileHashAsync, calculateContentHash } from
129
129
  export { findRfdbBinary, getBinaryNotFoundMessage, getPlatformDir } from './utils/findRfdbBinary.js';
130
130
  export type { FindBinaryOptions } from './utils/findRfdbBinary.js';
131
131
 
132
+ // RFDB server lifecycle (RFD-40)
133
+ export { startRfdbServer } from './utils/startRfdbServer.js';
134
+ export type { StartRfdbServerOptions } from './utils/startRfdbServer.js';
135
+
132
136
  // Module resolution utilities (REG-320)
133
137
  export {
134
138
  resolveModulePath,
@@ -275,6 +275,7 @@ export class JSASTAnalyzer extends Plugin {
275
275
  ]
276
276
  },
277
277
  dependencies: ['JSModuleIndexer'],
278
+ managesBatch: true,
278
279
  fields: [
279
280
  { name: 'object', fieldType: 'string', nodeTypes: ['CALL'] },
280
281
  { name: 'method', fieldType: 'string', nodeTypes: ['CALL'] },
@@ -382,7 +383,27 @@ export class JSASTAnalyzer extends Plugin {
382
383
  const queue = new PriorityQueue();
383
384
  const pool = new WorkerPool(context.workerCount || 10);
384
385
 
386
+ const deferIndex = context.deferIndexing ?? false;
387
+
385
388
  pool.registerHandler('ANALYZE_MODULE', async (task) => {
389
+ // Per-module batch: commit after each module to avoid buffering the entire
390
+ // graph in memory. Prevents connection timeouts on large codebases.
391
+ // REG-487: Pass deferIndex to skip per-commit index rebuild during bulk load.
392
+ if (graph.beginBatch && graph.commitBatch) {
393
+ graph.beginBatch();
394
+ try {
395
+ const result = await this.analyzeModule(task.data.module, graph, projectPath);
396
+ await graph.commitBatch(
397
+ ['JSASTAnalyzer', 'ANALYSIS', task.data.module.file],
398
+ deferIndex,
399
+ ['MODULE'],
400
+ );
401
+ return result;
402
+ } catch (err) {
403
+ if (graph.abortBatch) graph.abortBatch();
404
+ throw err;
405
+ }
406
+ }
386
407
  return await this.analyzeModule(task.data.module, graph, projectPath);
387
408
  });
388
409
 
@@ -408,7 +429,8 @@ export class JSASTAnalyzer extends Plugin {
408
429
  currentPlugin: 'JSASTAnalyzer',
409
430
  message: `Analyzing ${currentFile} (${completed}/${modulesToAnalyze.length})`,
410
431
  totalFiles: modulesToAnalyze.length,
411
- processedFiles: completed
432
+ processedFiles: completed,
433
+ currentService: currentFile
412
434
  });
413
435
  }
414
436
  }, 500);
@@ -429,6 +451,14 @@ export class JSASTAnalyzer extends Plugin {
429
451
 
430
452
  clearInterval(progressInterval);
431
453
 
454
+ // REG-487: Rebuild indexes after all deferred commits.
455
+ // This runs inside JSASTAnalyzer.execute() so downstream ANALYSIS plugins
456
+ // (which depend on JSASTAnalyzer) see rebuilt indexes.
457
+ if (deferIndex && graph.rebuildIndexes) {
458
+ logger.info('Rebuilding indexes after deferred bulk load...');
459
+ await graph.rebuildIndexes();
460
+ }
461
+
432
462
  if (context.onProgress) {
433
463
  context.onProgress({
434
464
  phase: 'analysis',
@@ -543,7 +573,8 @@ export class JSASTAnalyzer extends Plugin {
543
573
  currentPlugin: 'JSASTAnalyzer',
544
574
  message: `Processed ${result.module.name}`,
545
575
  totalFiles: modules.length,
546
- processedFiles: results.indexOf(result) + 1
576
+ processedFiles: results.indexOf(result) + 1,
577
+ currentService: result.module.file || result.module.name
547
578
  });
548
579
  }
549
580
  }
@@ -1705,6 +1736,11 @@ export class JSASTAnalyzer extends Plugin {
1705
1736
  if (processedConstructorCalls.has(nodeKey)) {
1706
1737
  return;
1707
1738
  }
1739
+
1740
+ // Skip in-function calls — handled by NewExpressionHandler in analyzeFunctionBody
1741
+ const functionParent = newPath.getFunctionParent();
1742
+ if (functionParent) return;
1743
+
1708
1744
  processedConstructorCalls.add(nodeKey);
1709
1745
 
1710
1746
  // Determine className from callee
@@ -1728,7 +1764,8 @@ export class JSASTAnalyzer extends Plugin {
1728
1764
  isBuiltin,
1729
1765
  file: module.file,
1730
1766
  line,
1731
- column
1767
+ column,
1768
+ parentScopeId: module.id
1732
1769
  });
1733
1770
 
1734
1771
  // REG-334: If this is Promise constructor with executor callback,
@@ -311,6 +311,15 @@ export class GraphBuilder {
311
311
  line: constructorCall.line,
312
312
  column: constructorCall.column
313
313
  } as GraphNode);
314
+
315
+ // SCOPE -> CONTAINS -> CONSTRUCTOR_CALL
316
+ if (constructorCall.parentScopeId) {
317
+ this._bufferEdge({
318
+ type: 'CONTAINS',
319
+ src: constructorCall.parentScopeId,
320
+ dst: constructorCall.id
321
+ });
322
+ }
314
323
  }
315
324
 
316
325
  // Phase 2: Delegate to domain builders
@@ -52,7 +52,7 @@ export class ModuleRuntimeBuilder implements DomainBuilder {
52
52
 
53
53
  private bufferImportNodes(module: ModuleNode, imports: ImportInfo[]): void {
54
54
  for (const imp of imports) {
55
- const { source, specifiers, line, column, isDynamic, isResolvable, dynamicPath } = imp;
55
+ const { source, specifiers, line, column, importKind, isDynamic, isResolvable, dynamicPath } = imp;
56
56
 
57
57
  // REG-273: Handle side-effect-only imports (no specifiers)
58
58
  if (specifiers.length === 0) {
@@ -66,7 +66,8 @@ export class ModuleRuntimeBuilder implements DomainBuilder {
66
66
  {
67
67
  imported: '*', // no specific export
68
68
  local: source, // source becomes local
69
- sideEffect: true // mark as side-effect import
69
+ sideEffect: true, // mark as side-effect import
70
+ importBinding: importKind || 'value'
70
71
  }
71
72
  );
72
73
 
@@ -110,6 +111,7 @@ export class ModuleRuntimeBuilder implements DomainBuilder {
110
111
  imported: spec.imported,
111
112
  local: spec.local,
112
113
  sideEffect: false, // regular imports are not side-effects
114
+ importBinding: spec.importKind === 'type' ? 'type' : (importKind || 'value'),
113
115
  // importType is auto-detected from imported field
114
116
  // Dynamic import fields
115
117
  isDynamic,
@@ -47,7 +47,8 @@ export class NewExpressionHandler extends FunctionBodyHandler {
47
47
  isBuiltin,
48
48
  file: ctx.module.file,
49
49
  line,
50
- column
50
+ column,
51
+ parentScopeId: ctx.getCurrentScopeId()
51
52
  });
52
53
 
53
54
  // REG-334: If this is Promise constructor with executor callback,
@@ -338,6 +338,7 @@ export interface ConstructorCallInfo {
338
338
  file: string;
339
339
  line: number;
340
340
  column: number;
341
+ parentScopeId?: string;
341
342
  }
342
343
 
343
344
  // === CLASS DECLARATION INFO ===
@@ -501,6 +502,7 @@ export interface ImportInfo {
501
502
  line: number;
502
503
  column?: number; // Column position for ImportNode
503
504
  specifiers: ImportSpecifier[];
505
+ importKind?: 'value' | 'type' | 'typeof'; // TypeScript: import type { ... }
504
506
  isDynamic?: boolean; // true for dynamic import() expressions
505
507
  isResolvable?: boolean; // true if path is a string literal (statically analyzable)
506
508
  dynamicPath?: string; // original expression for template/variable paths
@@ -509,6 +511,7 @@ export interface ImportInfo {
509
511
  export interface ImportSpecifier {
510
512
  imported: string; // имя в экспортируемом модуле (default, *, или имя)
511
513
  local: string; // имя в текущем модуле
514
+ importKind?: 'value' | 'type' | 'typeof'; // specifier-level: import { type X } from '...'
512
515
  }
513
516
 
514
517
  // === EXPORT INFO ===
@@ -41,6 +41,7 @@ export type ExtractVariableNamesCallback = (pattern: Node) => VariableInfo[];
41
41
  interface ImportSpecifierInfo {
42
42
  imported: string;
43
43
  local: string;
44
+ importKind?: 'value' | 'type' | 'typeof'; // specifier-level: import { type X } from '...'
44
45
  }
45
46
 
46
47
  /**
@@ -51,6 +52,7 @@ interface ImportInfo {
51
52
  specifiers: ImportSpecifierInfo[];
52
53
  line: number;
53
54
  column?: number;
55
+ importKind?: 'value' | 'type' | 'typeof'; // TypeScript: import type { ... }
54
56
  isDynamic?: boolean; // true for dynamic import() expressions
55
57
  isResolvable?: boolean; // true if path is a string literal (statically analyzable)
56
58
  dynamicPath?: string; // original expression for template/variable paths
@@ -106,13 +108,16 @@ export class ImportExportVisitor extends ASTVisitor {
106
108
  node.specifiers.forEach((spec) => {
107
109
  if (spec.type === 'ImportSpecifier') {
108
110
  // import { foo, bar } from './module'
111
+ // import { type Foo, bar } from './module' (specifier-level type)
109
112
  const importSpec = spec as ImportSpecifier;
110
113
  const importedName = importSpec.imported.type === 'Identifier'
111
114
  ? importSpec.imported.name
112
115
  : importSpec.imported.value;
116
+ const specKind = (importSpec as ImportSpecifier & { importKind?: string }).importKind;
113
117
  specifiers.push({
114
118
  imported: importedName,
115
- local: importSpec.local.name
119
+ local: importSpec.local.name,
120
+ importKind: specKind as ImportSpecifierInfo['importKind']
116
121
  });
117
122
  } else if (spec.type === 'ImportDefaultSpecifier') {
118
123
  // import foo from './module'
@@ -135,7 +140,8 @@ export class ImportExportVisitor extends ASTVisitor {
135
140
  source,
136
141
  specifiers,
137
142
  line: getLine(node),
138
- column: getColumn(node)
143
+ column: getColumn(node),
144
+ importKind: (node as ImportDeclaration & { importKind?: string }).importKind as ImportInfo['importKind']
139
145
  });
140
146
  },
141
147
 
@@ -102,20 +102,30 @@ export class ImportExportLinker extends Plugin {
102
102
  const currentDir = dirname(imp.file!);
103
103
  const basePath = join(currentDir, imp.source!);
104
104
 
105
- // Try different extensions
105
+ // Build candidate paths: append extensions + TypeScript .js→.ts redirect
106
+ const candidates: string[] = [];
106
107
  const extensions = ['', '.js', '.ts', '.jsx', '.tsx', '/index.js', '/index.ts'];
108
+ for (const ext of extensions) {
109
+ candidates.push(basePath + ext);
110
+ }
111
+ // TypeScript convention: imports use .js but actual files are .ts
112
+ if (basePath.endsWith('.js')) {
113
+ candidates.push(basePath.slice(0, -3) + '.ts');
114
+ candidates.push(basePath.slice(0, -3) + '.tsx');
115
+ } else if (basePath.endsWith('.jsx')) {
116
+ candidates.push(basePath.slice(0, -4) + '.tsx');
117
+ }
118
+
107
119
  let targetFile: string | null = null;
108
120
  let targetExports: Map<string, ExportNode> | null = null;
109
121
 
110
- for (const ext of extensions) {
111
- const testPath = basePath + ext;
122
+ for (const testPath of candidates) {
112
123
  if (exportIndex.has(testPath)) {
113
124
  targetFile = testPath;
114
125
  targetExports = exportIndex.get(testPath)!;
115
126
  break;
116
127
  }
117
- // Also check modulesByFile in case exports haven't been indexed yet
118
- if (!targetFile && modulesByFile.has(testPath)) {
128
+ if (modulesByFile.has(testPath)) {
119
129
  targetFile = testPath;
120
130
  targetExports = exportIndex.get(testPath) || new Map();
121
131
  break;