prjct-cli 0.44.1 → 0.45.0

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.
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Imports Tool - Build import/dependency graphs
3
+ *
4
+ * Analyzes:
5
+ * - What a file imports
6
+ * - What files import this file (reverse lookup)
7
+ * - Dependency tree to a given depth
8
+ *
9
+ * @module context-tools/imports-tool
10
+ * @version 1.0.0
11
+ */
12
+
13
+ import fs from 'fs/promises'
14
+ import path from 'path'
15
+ import { exec as execCallback } from 'child_process'
16
+ import { promisify } from 'util'
17
+ import type {
18
+ ImportsToolOutput,
19
+ ImportRelation,
20
+ ImportedBy,
21
+ DependencyNode,
22
+ } from './types'
23
+ import { isNotFoundError } from '../types/fs'
24
+
25
+ const exec = promisify(execCallback)
26
+
27
+ // =============================================================================
28
+ // Import Patterns by Language
29
+ // =============================================================================
30
+
31
+ interface ImportPattern {
32
+ pattern: RegExp
33
+ sourceIndex: number
34
+ namesIndex?: number
35
+ isDefault?: boolean
36
+ isNamespace?: boolean
37
+ }
38
+
39
+ /**
40
+ * TypeScript/JavaScript import patterns
41
+ */
42
+ const TS_IMPORT_PATTERNS: ImportPattern[] = [
43
+ // import { x, y } from 'module'
44
+ {
45
+ pattern: /import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g,
46
+ sourceIndex: 2,
47
+ namesIndex: 1,
48
+ },
49
+ // import x from 'module'
50
+ {
51
+ pattern: /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g,
52
+ sourceIndex: 2,
53
+ namesIndex: 1,
54
+ isDefault: true,
55
+ },
56
+ // import * as x from 'module'
57
+ {
58
+ pattern: /import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g,
59
+ sourceIndex: 2,
60
+ namesIndex: 1,
61
+ isNamespace: true,
62
+ },
63
+ // import 'module' (side-effect)
64
+ {
65
+ pattern: /import\s*['"]([^'"]+)['"]/g,
66
+ sourceIndex: 1,
67
+ },
68
+ // require('module')
69
+ {
70
+ pattern: /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
71
+ sourceIndex: 1,
72
+ },
73
+ // Dynamic import
74
+ {
75
+ pattern: /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
76
+ sourceIndex: 1,
77
+ },
78
+ ]
79
+
80
+ /**
81
+ * Python import patterns
82
+ */
83
+ const PYTHON_IMPORT_PATTERNS: ImportPattern[] = [
84
+ // from module import x, y
85
+ {
86
+ pattern: /from\s+([\w.]+)\s+import\s+([^;\n]+)/g,
87
+ sourceIndex: 1,
88
+ namesIndex: 2,
89
+ },
90
+ // import module
91
+ {
92
+ pattern: /^import\s+([\w.]+)(?:\s+as\s+\w+)?$/gm,
93
+ sourceIndex: 1,
94
+ },
95
+ ]
96
+
97
+ /**
98
+ * Go import patterns
99
+ */
100
+ const GO_IMPORT_PATTERNS: ImportPattern[] = [
101
+ // import "module"
102
+ {
103
+ pattern: /import\s*"([^"]+)"/g,
104
+ sourceIndex: 1,
105
+ },
106
+ // import ( "module1" "module2" )
107
+ {
108
+ pattern: /import\s*\([^)]*"([^"]+)"[^)]*\)/g,
109
+ sourceIndex: 1,
110
+ },
111
+ ]
112
+
113
+ /**
114
+ * Language to import patterns mapping
115
+ */
116
+ const IMPORT_PATTERNS: Record<string, ImportPattern[]> = {
117
+ typescript: TS_IMPORT_PATTERNS,
118
+ javascript: TS_IMPORT_PATTERNS,
119
+ python: PYTHON_IMPORT_PATTERNS,
120
+ go: GO_IMPORT_PATTERNS,
121
+ }
122
+
123
+ /**
124
+ * Extension to language mapping
125
+ */
126
+ const EXT_TO_LANG: Record<string, string> = {
127
+ '.ts': 'typescript',
128
+ '.tsx': 'typescript',
129
+ '.js': 'javascript',
130
+ '.jsx': 'javascript',
131
+ '.mjs': 'javascript',
132
+ '.cjs': 'javascript',
133
+ '.py': 'python',
134
+ '.go': 'go',
135
+ }
136
+
137
+ // =============================================================================
138
+ // Main Functions
139
+ // =============================================================================
140
+
141
+ /**
142
+ * Analyze imports for a file
143
+ *
144
+ * @param filePath - Path to the file
145
+ * @param projectPath - Project root path
146
+ * @param options - Analysis options
147
+ * @returns Import analysis with metrics
148
+ */
149
+ export async function analyzeImports(
150
+ filePath: string,
151
+ projectPath: string = process.cwd(),
152
+ options: {
153
+ reverse?: boolean // Include files that import this
154
+ depth?: number // Dependency tree depth (0 = no tree)
155
+ } = {}
156
+ ): Promise<ImportsToolOutput> {
157
+ const absolutePath = path.isAbsolute(filePath)
158
+ ? filePath
159
+ : path.join(projectPath, filePath)
160
+
161
+ // Read file content
162
+ let content: string
163
+ try {
164
+ content = await fs.readFile(absolutePath, 'utf-8')
165
+ } catch (error) {
166
+ if (isNotFoundError(error)) {
167
+ return {
168
+ file: filePath,
169
+ imports: [],
170
+ importedBy: [],
171
+ metrics: {
172
+ totalImports: 0,
173
+ externalImports: 0,
174
+ internalImports: 0,
175
+ importedByCount: 0,
176
+ },
177
+ }
178
+ }
179
+ throw error
180
+ }
181
+
182
+ // Detect language
183
+ const ext = path.extname(filePath).toLowerCase()
184
+ const language = EXT_TO_LANG[ext] || 'unknown'
185
+ const patterns = IMPORT_PATTERNS[language] || []
186
+
187
+ // Extract imports
188
+ const imports = extractImports(content, patterns, absolutePath, projectPath)
189
+
190
+ // Get reverse imports if requested
191
+ let importedBy: ImportedBy[] = []
192
+ if (options.reverse) {
193
+ importedBy = await findImportedBy(filePath, projectPath)
194
+ }
195
+
196
+ // Build dependency tree if requested
197
+ let dependencyTree: DependencyNode | undefined
198
+ if (options.depth && options.depth > 0) {
199
+ dependencyTree = await buildDependencyTree(
200
+ filePath,
201
+ projectPath,
202
+ options.depth
203
+ )
204
+ }
205
+
206
+ // Calculate metrics
207
+ const externalImports = imports.filter((i) => i.isExternal).length
208
+ const internalImports = imports.filter((i) => !i.isExternal).length
209
+
210
+ return {
211
+ file: filePath,
212
+ imports,
213
+ importedBy,
214
+ dependencyTree,
215
+ metrics: {
216
+ totalImports: imports.length,
217
+ externalImports,
218
+ internalImports,
219
+ importedByCount: importedBy.length,
220
+ },
221
+ }
222
+ }
223
+
224
+ // =============================================================================
225
+ // Helper Functions
226
+ // =============================================================================
227
+
228
+ /**
229
+ * Extract imports from file content
230
+ */
231
+ function extractImports(
232
+ content: string,
233
+ patterns: ImportPattern[],
234
+ absolutePath: string,
235
+ projectPath: string
236
+ ): ImportRelation[] {
237
+ const imports: ImportRelation[] = []
238
+ const seen = new Set<string>()
239
+
240
+ for (const patternDef of patterns) {
241
+ patternDef.pattern.lastIndex = 0
242
+
243
+ let match
244
+ while ((match = patternDef.pattern.exec(content)) !== null) {
245
+ const source = match[patternDef.sourceIndex]
246
+ if (!source || seen.has(source)) continue
247
+ seen.add(source)
248
+
249
+ // Parse imported names
250
+ let importedNames: string[] | undefined
251
+ if (patternDef.namesIndex !== undefined) {
252
+ const namesStr = match[patternDef.namesIndex]
253
+ if (namesStr) {
254
+ importedNames = namesStr
255
+ .split(',')
256
+ .map((n) => n.trim().split(' as ')[0].trim())
257
+ .filter(Boolean)
258
+ }
259
+ }
260
+
261
+ // Determine if external
262
+ const isExternal =
263
+ !source.startsWith('.') && !source.startsWith('/') && !source.startsWith('@/')
264
+
265
+ // Resolve internal imports
266
+ let resolved: string | null = null
267
+ if (!isExternal) {
268
+ resolved = resolveImport(source, absolutePath, projectPath)
269
+ }
270
+
271
+ imports.push({
272
+ source,
273
+ resolved,
274
+ isExternal,
275
+ importedNames,
276
+ isDefault: patternDef.isDefault,
277
+ isNamespace: patternDef.isNamespace,
278
+ })
279
+ }
280
+ }
281
+
282
+ return imports
283
+ }
284
+
285
+ /**
286
+ * Resolve a relative import to an absolute path
287
+ */
288
+ function resolveImport(
289
+ source: string,
290
+ fromFile: string,
291
+ projectPath: string
292
+ ): string | null {
293
+ const fileDir = path.dirname(fromFile)
294
+
295
+ // Handle path alias like @/
296
+ if (source.startsWith('@/')) {
297
+ const aliasPath = path.join(projectPath, 'src', source.slice(2))
298
+ return tryResolve(aliasPath, projectPath)
299
+ }
300
+
301
+ // Regular relative import
302
+ const resolved = path.resolve(fileDir, source)
303
+ return tryResolve(resolved, projectPath)
304
+ }
305
+
306
+ /**
307
+ * Try to resolve a path, adding extensions if needed
308
+ */
309
+ function tryResolve(basePath: string, projectPath: string): string | null {
310
+ const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.js']
311
+
312
+ for (const ext of extensions) {
313
+ const fullPath = basePath + ext
314
+ try {
315
+ // Check synchronously (we're in a hot path)
316
+ const fs = require('fs')
317
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
318
+ return path.relative(projectPath, fullPath)
319
+ }
320
+ } catch {
321
+ continue
322
+ }
323
+ }
324
+
325
+ return null
326
+ }
327
+
328
+ /**
329
+ * Find all files that import the target file
330
+ */
331
+ async function findImportedBy(
332
+ filePath: string,
333
+ projectPath: string
334
+ ): Promise<ImportedBy[]> {
335
+ const importedBy: ImportedBy[] = []
336
+
337
+ // Get the base name without extension for matching
338
+ const baseName = path.basename(filePath, path.extname(filePath))
339
+ const dirName = path.dirname(filePath)
340
+
341
+ try {
342
+ // Use ripgrep if available, otherwise grep
343
+ const searchPatterns = [
344
+ `from ['"].*${baseName}['"]`,
345
+ `from ['"]\\./${baseName}['"]`,
346
+ `import\\(['"'].*${baseName}['"]`,
347
+ `require\\(['"'].*${baseName}['"]`,
348
+ ]
349
+
350
+ const pattern = searchPatterns.join('|')
351
+
352
+ const { stdout } = await exec(
353
+ `grep -r -l -E '${pattern}' --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' . 2>/dev/null || true`,
354
+ { cwd: projectPath, maxBuffer: 10 * 1024 * 1024 }
355
+ )
356
+
357
+ const files = stdout
358
+ .trim()
359
+ .split('\n')
360
+ .filter(Boolean)
361
+ .map((f) => f.replace(/^\.\//, ''))
362
+ .filter((f) => f !== filePath) // Exclude self
363
+
364
+ for (const file of files) {
365
+ importedBy.push({ file })
366
+ }
367
+ } catch (error) {
368
+ // grep not available or error - return empty
369
+ }
370
+
371
+ return importedBy
372
+ }
373
+
374
+ /**
375
+ * Build a dependency tree to a given depth
376
+ */
377
+ async function buildDependencyTree(
378
+ filePath: string,
379
+ projectPath: string,
380
+ maxDepth: number,
381
+ currentDepth: number = 0,
382
+ visited: Set<string> = new Set()
383
+ ): Promise<DependencyNode> {
384
+ const node: DependencyNode = {
385
+ file: filePath,
386
+ imports: [],
387
+ depth: currentDepth,
388
+ }
389
+
390
+ if (currentDepth >= maxDepth || visited.has(filePath)) {
391
+ return node
392
+ }
393
+
394
+ visited.add(filePath)
395
+
396
+ // Get imports for this file
397
+ const analysis = await analyzeImports(filePath, projectPath, {
398
+ reverse: false,
399
+ depth: 0,
400
+ })
401
+
402
+ // Recursively build tree for internal imports
403
+ for (const imp of analysis.imports) {
404
+ if (!imp.isExternal && imp.resolved) {
405
+ const childNode = await buildDependencyTree(
406
+ imp.resolved,
407
+ projectPath,
408
+ maxDepth,
409
+ currentDepth + 1,
410
+ visited
411
+ )
412
+ node.imports.push(childNode)
413
+ }
414
+ }
415
+
416
+ return node
417
+ }
418
+
419
+ // =============================================================================
420
+ // Exports
421
+ // =============================================================================
422
+
423
+ export default { analyzeImports }