@getmikk/core 1.8.1 → 1.8.3

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.
@@ -5,9 +5,18 @@ import { hashContent } from '../../hash/file-hasher.js'
5
5
  /**
6
6
  * TypeScript AST extractor walks the TypeScript AST using the TS Compiler API
7
7
  * and extracts functions, classes, imports, exports and call relationships.
8
+ *
9
+ * ID FORMAT: fn:<filePath>:<name> — no startLine suffix.
10
+ * Same-name collisions within the same file are resolved with a #2, #3 suffix.
11
+ * Removing startLine from the ID means:
12
+ * 1. graph-builder local lookup (fn:file:name) matches without extra info
13
+ * 2. lock-reader parseEntityKey correctly extracts name via lastIndexOf(':')
14
+ * 3. Incremental caching is not invalidated by line-number shifts on edits
8
15
  */
9
16
  export class TypeScriptExtractor {
10
17
  protected readonly sourceFile: ts.SourceFile
18
+ /** Per-file collision counter: name -> count of times seen so far */
19
+ private nameCounter = new Map<string, number>()
11
20
 
12
21
  constructor(
13
22
  protected readonly filePath: string,
@@ -30,6 +39,23 @@ export class TypeScriptExtractor {
30
39
  return ts.ScriptKind.TS
31
40
  }
32
41
 
42
+ /**
43
+ * Allocate a stable, collision-free function ID.
44
+ * First occurrence: fn:file:name
45
+ * Second occurrence: fn:file:name#2
46
+ * Third occurrence: fn:file:name#3 … etc.
47
+ */
48
+ private allocateFnId(name: string): string {
49
+ const count = (this.nameCounter.get(name) ?? 0) + 1
50
+ this.nameCounter.set(name, count)
51
+ return count === 1 ? `fn:${this.filePath}:${name}` : `fn:${this.filePath}:${name}#${count}`
52
+ }
53
+
54
+ /** Reset the collision counter (call before each full extraction pass if reused) */
55
+ resetCounters(): void {
56
+ this.nameCounter.clear()
57
+ }
58
+
33
59
  /** Extract all top-level and variable-assigned functions */
34
60
  extractFunctions(): ParsedFunction[] {
35
61
  const functions: ParsedFunction[] = []
@@ -78,7 +104,7 @@ export class TypeScriptExtractor {
78
104
  name: node.name.text,
79
105
  type: 'interface',
80
106
  file: this.filePath,
81
- startLine: this.getLineNumber(node.getStart()),
107
+ startLine: this.getLineNumber(node.getStart(this.sourceFile)),
82
108
  endLine: this.getLineNumber(node.getEnd()),
83
109
  isExported: this.hasExportModifier(node),
84
110
  ...(tp.length > 0 ? { typeParameters: tp } : {}),
@@ -91,7 +117,7 @@ export class TypeScriptExtractor {
91
117
  name: node.name.text,
92
118
  type: 'type',
93
119
  file: this.filePath,
94
- startLine: this.getLineNumber(node.getStart()),
120
+ startLine: this.getLineNumber(node.getStart(this.sourceFile)),
95
121
  endLine: this.getLineNumber(node.getEnd()),
96
122
  isExported: this.hasExportModifier(node),
97
123
  ...(tp.length > 0 ? { typeParameters: tp } : {}),
@@ -106,7 +132,7 @@ export class TypeScriptExtractor {
106
132
  name: decl.name.text,
107
133
  type: 'const',
108
134
  file: this.filePath,
109
- startLine: this.getLineNumber(node.getStart()),
135
+ startLine: this.getLineNumber(node.getStart(this.sourceFile)),
110
136
  endLine: this.getLineNumber(node.getEnd()),
111
137
  isExported: this.hasExportModifier(node),
112
138
  purpose: this.extractPurpose(node),
@@ -299,7 +325,7 @@ export class TypeScriptExtractor {
299
325
  handler,
300
326
  middlewares,
301
327
  file: this.filePath,
302
- line: this.getLineNumber(node.getStart()),
328
+ line: this.getLineNumber(node.getStart(this.sourceFile)),
303
329
  })
304
330
  }
305
331
  }
@@ -313,7 +339,8 @@ export class TypeScriptExtractor {
313
339
 
314
340
  protected parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
315
341
  const name = node.name!.text
316
- const startLine = this.getLineNumber(node.getStart())
342
+ const id = this.allocateFnId(name)
343
+ const startLine = this.getLineNumber(node.getStart(this.sourceFile))
317
344
  const endLine = this.getLineNumber(node.getEnd())
318
345
  const params = this.extractParams(node.parameters)
319
346
  const returnType = normalizeTypeAnnotation(node.type ? node.type.getText(this.sourceFile) : 'void')
@@ -324,7 +351,7 @@ export class TypeScriptExtractor {
324
351
  const bodyText = node.getText(this.sourceFile)
325
352
 
326
353
  return {
327
- id: `fn:${this.filePath}:${name}`,
354
+ id,
328
355
  name,
329
356
  file: this.filePath,
330
357
  startLine,
@@ -350,7 +377,8 @@ export class TypeScriptExtractor {
350
377
  fn: ts.ArrowFunction | ts.FunctionExpression
351
378
  ): ParsedFunction {
352
379
  const name = (decl.name as ts.Identifier).text
353
- const startLine = this.getLineNumber(stmt.getStart())
380
+ const id = this.allocateFnId(name)
381
+ const startLine = this.getLineNumber(stmt.getStart(this.sourceFile))
354
382
  const endLine = this.getLineNumber(stmt.getEnd())
355
383
  const params = this.extractParams(fn.parameters)
356
384
  const returnType = normalizeTypeAnnotation(fn.type ? fn.type.getText(this.sourceFile) : 'void')
@@ -361,7 +389,7 @@ export class TypeScriptExtractor {
361
389
  const bodyText = stmt.getText(this.sourceFile)
362
390
 
363
391
  return {
364
- id: `fn:${this.filePath}:${name}`,
392
+ id,
365
393
  name,
366
394
  file: this.filePath,
367
395
  startLine,
@@ -383,7 +411,7 @@ export class TypeScriptExtractor {
383
411
 
384
412
  protected parseClass(node: ts.ClassDeclaration): ParsedClass {
385
413
  const name = node.name!.text
386
- const startLine = this.getLineNumber(node.getStart())
414
+ const startLine = this.getLineNumber(node.getStart(this.sourceFile))
387
415
  const endLine = this.getLineNumber(node.getEnd())
388
416
  const methods: ParsedFunction[] = []
389
417
  const decorators = this.extractDecorators(node)
@@ -392,15 +420,16 @@ export class TypeScriptExtractor {
392
420
  for (const member of node.members) {
393
421
  if (ts.isConstructorDeclaration(member)) {
394
422
  // Track class constructors as methods
395
- const mStartLine = this.getLineNumber(member.getStart())
423
+ const mStartLine = this.getLineNumber(member.getStart(this.sourceFile))
396
424
  const mEndLine = this.getLineNumber(member.getEnd())
397
425
  const params = this.extractParams(member.parameters)
398
426
  const calls = this.extractCalls(member)
399
427
  const bodyText = member.getText(this.sourceFile)
428
+ const methodName = `${name}.constructor`
400
429
 
401
430
  methods.push({
402
- id: `fn:${this.filePath}:${name}.constructor`,
403
- name: `${name}.constructor`,
431
+ id: this.allocateFnId(methodName),
432
+ name: methodName,
404
433
  file: this.filePath,
405
434
  startLine: mStartLine,
406
435
  endLine: mEndLine,
@@ -416,8 +445,8 @@ export class TypeScriptExtractor {
416
445
  detailedLines: this.extractDetailedLines(member),
417
446
  })
418
447
  } else if (ts.isMethodDeclaration(member) && member.name) {
419
- const methodName = member.name.getText(this.sourceFile)
420
- const mStartLine = this.getLineNumber(member.getStart())
448
+ const methodName = `${name}.${member.name.getText(this.sourceFile)}`
449
+ const mStartLine = this.getLineNumber(member.getStart(this.sourceFile))
421
450
  const mEndLine = this.getLineNumber(member.getEnd())
422
451
  const params = this.extractParams(member.parameters)
423
452
  const returnType = normalizeTypeAnnotation(member.type ? member.type.getText(this.sourceFile) : 'void')
@@ -428,8 +457,8 @@ export class TypeScriptExtractor {
428
457
  const bodyText = member.getText(this.sourceFile)
429
458
 
430
459
  methods.push({
431
- id: `fn:${this.filePath}:${name}.${methodName}`,
432
- name: `${name}.${methodName}`,
460
+ id: this.allocateFnId(methodName),
461
+ name: methodName,
433
462
  file: this.filePath,
434
463
  startLine: mStartLine,
435
464
  endLine: mEndLine,
@@ -471,6 +500,9 @@ export class TypeScriptExtractor {
471
500
  let isDefault = false
472
501
 
473
502
  if (node.importClause) {
503
+ // Skip type-only imports: import type { Foo } or import type * as X
504
+ if (node.importClause.isTypeOnly) return null
505
+
474
506
  // import Foo from './module' (default import)
475
507
  if (node.importClause.name) {
476
508
  names.push(node.importClause.name.text)
@@ -480,7 +512,10 @@ export class TypeScriptExtractor {
480
512
  if (node.importClause.namedBindings) {
481
513
  if (ts.isNamedImports(node.importClause.namedBindings)) {
482
514
  for (const element of node.importClause.namedBindings.elements) {
483
- names.push(element.name.text)
515
+ // Skip individual type-only elements: import { type Foo }
516
+ if (!element.isTypeOnly) {
517
+ names.push(element.name.text)
518
+ }
484
519
  }
485
520
  }
486
521
  // import * as foo from './module'
@@ -490,9 +525,6 @@ export class TypeScriptExtractor {
490
525
  }
491
526
  }
492
527
 
493
- // Skip type-only imports
494
- if (node.importClause?.isTypeOnly) return null
495
-
496
528
  return {
497
529
  source,
498
530
  resolvedPath: '', // Filled in by resolver
@@ -502,9 +534,21 @@ export class TypeScriptExtractor {
502
534
  }
503
535
  }
504
536
 
505
- /** Extract function/method call expressions from a node (including new Foo()) */
537
+ /**
538
+ * Extract function/method call expressions from the BODY of a node only.
539
+ * Deliberately skips parameter lists, decorator expressions and type annotations
540
+ * to avoid recording spurious calls from default-param expressions like
541
+ * fn(config = getDefaultConfig()) → getDefaultConfig should NOT appear in calls
542
+ *
543
+ * Strategy: walk only the body block, not the full node subtree.
544
+ */
506
545
  protected extractCalls(node: ts.Node): string[] {
507
546
  const calls: string[] = []
547
+
548
+ // Determine the actual body to walk — skip params, type nodes, decorators
549
+ const bodyNode = getBodyNode(node)
550
+ if (!bodyNode) return []
551
+
508
552
  const walkCalls = (n: ts.Node) => {
509
553
  if (ts.isCallExpression(n)) {
510
554
  const callee = n.expression
@@ -526,7 +570,7 @@ export class TypeScriptExtractor {
526
570
  }
527
571
  ts.forEachChild(n, walkCalls)
528
572
  }
529
- ts.forEachChild(node, walkCalls)
573
+ ts.forEachChild(bodyNode, walkCalls)
530
574
  return [...new Set(calls)] // deduplicate
531
575
  }
532
576
 
@@ -541,9 +585,13 @@ export class TypeScriptExtractor {
541
585
  const comment = fullText.slice(range.pos, range.end)
542
586
  let clean = ''
543
587
  if (comment.startsWith('/**') || comment.startsWith('/*')) {
544
- clean = comment.replace(/[\/\*]/g, '').trim()
588
+ clean = comment
589
+ .replace(/^\/\*+/, '') // remove leading /* or /**
590
+ .replace(/\*+\/$/, '') // remove trailing */
591
+ .replace(/^\s*\*+\s?/gm, '') // remove leading * on each line
592
+ .trim()
545
593
  } else if (comment.startsWith('//')) {
546
- clean = comment.replace(/\/\//g, '').trim()
594
+ clean = comment.replace(/^\/\/+\s?/, '').trim()
547
595
  }
548
596
 
549
597
  // Skip divider lines (lines with 3+ repeated special characters)
@@ -552,7 +600,6 @@ export class TypeScriptExtractor {
552
600
  if (clean) meaningfulLines.push(clean)
553
601
  }
554
602
 
555
- // Return the first meaningful line in JSDoc, the first line is the summary.
556
603
  const fromComment = meaningfulLines.length > 0 ? meaningfulLines[0].split('\n')[0].trim() : ''
557
604
  if (fromComment) return fromComment
558
605
  }
@@ -574,7 +621,7 @@ export class TypeScriptExtractor {
574
621
  if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) return node.name.text
575
622
  if (ts.isConstructorDeclaration(node)) return 'constructor'
576
623
  if ((ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
577
- return (node as any).name.text
624
+ return (node as ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.ClassDeclaration).name!.text
578
625
  }
579
626
  return ''
580
627
  }
@@ -606,14 +653,14 @@ export class TypeScriptExtractor {
606
653
  const walkErrors = (n: ts.Node) => {
607
654
  if (ts.isTryStatement(n)) {
608
655
  errors.push({
609
- line: this.getLineNumber(n.getStart()),
656
+ line: this.getLineNumber(n.getStart(this.sourceFile)),
610
657
  type: 'try-catch',
611
658
  detail: 'try-catch block'
612
659
  })
613
660
  }
614
661
  if (ts.isThrowStatement(n)) {
615
662
  errors.push({
616
- line: this.getLineNumber(n.getStart()),
663
+ line: this.getLineNumber(n.getStart(this.sourceFile)),
617
664
  type: 'throw',
618
665
  detail: n.expression ? n.expression.getText(this.sourceFile) : 'throw error'
619
666
  })
@@ -630,18 +677,16 @@ export class TypeScriptExtractor {
630
677
  const walkBlocks = (n: ts.Node) => {
631
678
  if (ts.isIfStatement(n) || ts.isSwitchStatement(n)) {
632
679
  blocks.push({
633
- startLine: this.getLineNumber(n.getStart()),
680
+ startLine: this.getLineNumber(n.getStart(this.sourceFile)),
634
681
  endLine: this.getLineNumber(n.getEnd()),
635
682
  blockType: 'ControlFlow'
636
683
  })
637
684
  } else if (ts.isForStatement(n) || ts.isWhileStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n)) {
638
685
  blocks.push({
639
- startLine: this.getLineNumber(n.getStart()),
686
+ startLine: this.getLineNumber(n.getStart(this.sourceFile)),
640
687
  endLine: this.getLineNumber(n.getEnd()),
641
688
  blockType: 'Loop'
642
689
  })
643
- } else if (ts.isVariableStatement(n) || ts.isExpressionStatement(n)) {
644
- // Ignore single lines for brevity unless part of larger logical units
645
690
  }
646
691
  ts.forEachChild(n, walkBlocks)
647
692
  }
@@ -694,15 +739,45 @@ export class TypeScriptExtractor {
694
739
  return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
695
740
  }
696
741
 
697
- /** Walk the top-level children of a node (non-recursive callbacks decide depth) */
742
+ /**
743
+ * Walk ALL descendant nodes recursively (depth-first).
744
+ * This ensures nested functions inside if/try/namespace/module blocks are found.
745
+ *
746
+ * NOTE: The callback controls depth — returning early from the callback is NOT
747
+ * supported here. If you need to stop at certain depths (e.g. don't recurse
748
+ * into nested function bodies), handle that in the callback by checking node kind.
749
+ */
698
750
  protected walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
699
751
  ts.forEachChild(node, (child) => {
700
752
  callback(child)
753
+ this.walkNode(child, callback)
701
754
  })
702
755
  }
703
756
  }
704
757
 
705
- //
758
+ // ---------------------------------------------------------------------------
759
+ // Module-level helpers
760
+ // ---------------------------------------------------------------------------
761
+
762
+ /**
763
+ * Return the body node of a function-like node to limit call extraction scope.
764
+ * Skips parameter lists, type annotations and decorators.
765
+ * Returns null for nodes with no body (abstract methods, overload signatures).
766
+ */
767
+ function getBodyNode(node: ts.Node): ts.Node | null {
768
+ if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) {
769
+ return (node as ts.FunctionLikeDeclaration).body ?? null
770
+ }
771
+ if (ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node)) {
772
+ return (node as ts.MethodDeclaration).body ?? null
773
+ }
774
+ // For class declarations, walk members but not decorators/heritage
775
+ if (ts.isClassDeclaration(node)) {
776
+ return node
777
+ }
778
+ // For anything else (e.g. class body node) walk as-is
779
+ return node
780
+ }
706
781
 
707
782
  /**
708
783
  * Derive a human-readable purpose sentence from a camelCase/PascalCase identifier.
@@ -5,6 +5,7 @@ import { TypeScriptExtractor } from './ts-extractor.js'
5
5
  import { TypeScriptResolver } from './ts-resolver.js'
6
6
  import { hashContent } from '../../hash/file-hasher.js'
7
7
  import type { ParsedFile } from '../types.js'
8
+ import { MIN_FILES_FOR_COMPLETE_SCAN, parseJsonWithComments } from '../parser-constants.js'
8
9
 
9
10
  /**
10
11
  * TypeScript parser — uses TS Compiler API to parse .ts/.tsx files
@@ -21,23 +22,21 @@ export class TypeScriptParser extends BaseParser {
21
22
  const exports = extractor.extractExports()
22
23
  const routes = extractor.extractRoutes()
23
24
 
24
- // Cross-reference: if a function/class/generic is named in an export { Name }
25
- // or export default declaration, mark it as exported.
26
- const exportedNames = new Set(exports.map(e => e.name))
25
+ // Cross-reference: re-export declarations (`export { Name }` or
26
+ // `export { X as Y } from './m'`) may refer to symbols whose declaration
27
+ // doesn't carry an export keyword. Mark them as exported here.
28
+ // Exclude `type: 'default'` to avoid marking an unrelated local called 'default'.
29
+ const exportedNonDefault = new Set(
30
+ exports.filter(e => e.type !== 'default').map(e => e.name)
31
+ )
27
32
  for (const fn of functions) {
28
- if (!fn.isExported && exportedNames.has(fn.name)) {
29
- fn.isExported = true
30
- }
33
+ if (!fn.isExported && exportedNonDefault.has(fn.name)) fn.isExported = true
31
34
  }
32
35
  for (const cls of classes) {
33
- if (!cls.isExported && exportedNames.has(cls.name)) {
34
- cls.isExported = true
35
- }
36
+ if (!cls.isExported && exportedNonDefault.has(cls.name)) cls.isExported = true
36
37
  }
37
38
  for (const gen of generics) {
38
- if (!gen.isExported && exportedNames.has(gen.name)) {
39
- gen.isExported = true
40
- }
39
+ if (!gen.isExported && exportedNonDefault.has(gen.name)) gen.isExported = true
41
40
  }
42
41
 
43
42
  return {
@@ -58,7 +57,14 @@ export class TypeScriptParser extends BaseParser {
58
57
  resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
59
58
  const tsConfigPaths = loadTsConfigPaths(projectRoot)
60
59
  const resolver = new TypeScriptResolver(projectRoot, tsConfigPaths)
61
- const allFilePaths = files.map(f => f.path)
60
+
61
+ // Only pass the project file list when it is large enough to be a meaningful
62
+ // scan. Sparse lists (< MIN_FILES_FOR_COMPLETE_SCAN files) cause alias
63
+ // resolution lookups to fail with '', so we only trust the list once it is
64
+ // sufficiently large. With an empty list the resolver falls back to extension
65
+ // probing, which is safe for alias-defined paths.
66
+ const allFilePaths = files.length >= MIN_FILES_FOR_COMPLETE_SCAN ? files.map(f => f.path) : []
67
+
62
68
  return files.map(file => ({
63
69
  ...file,
64
70
  imports: file.imports.map(imp => resolver.resolve(imp, file.path, allFilePaths)),
@@ -79,7 +85,7 @@ export class TypeScriptParser extends BaseParser {
79
85
  * - extends with relative paths (./tsconfig.base.json)
80
86
  * - extends with node_modules packages (@tsconfig/node-lts)
81
87
  * - baseUrl prefix so aliases like "@/*" → ["src/*"] resolve correctly
82
- * - JSON5-style comments (line and block comments)
88
+ * - JSON5-style comments (line and block comments) via the shared helper
83
89
  */
84
90
  function loadTsConfigPaths(projectRoot: string): Record<string, string[]> {
85
91
  const candidates = ['tsconfig.json', 'tsconfig.base.json']
@@ -121,13 +127,11 @@ function loadTsConfigWithExtends(configPath: string, visited: Set<string>): any
121
127
  return {}
122
128
  }
123
129
 
124
- // Strip JSON5 comments
125
- const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '')
126
130
  let config: any
127
131
  try {
128
- config = JSON.parse(stripped)
132
+ config = parseJsonWithComments(raw)
129
133
  } catch {
130
- try { config = JSON.parse(raw) } catch { return {} }
134
+ return {}
131
135
  }
132
136
 
133
137
  if (!config.extends) return config
@@ -6,8 +6,15 @@ interface TSConfigPaths {
6
6
  }
7
7
 
8
8
  /**
9
- * Resolves TypeScript import paths to absolute project-relative paths.
10
- * Handles: relative imports, path aliases, index files, extension inference.
9
+ * TypeScriptResolver resolves TS/TSX import paths to project-relative files.
10
+ *
11
+ * Handles:
12
+ * - Relative ESM imports: import './utils' → ./utils.ts / ./utils/index.ts / ...
13
+ * - Path aliases from tsconfig.json compilerOptions.paths
14
+ * - Mixed TS/JS projects: probes .ts, .tsx, .js, .jsx in that order
15
+ *
16
+ * Performance: allProjectFiles is converted to a Set internally for O(1) checks.
17
+ * All alias targets are tried in order (not just targets[0]).
11
18
  */
12
19
  export class TypeScriptResolver {
13
20
  private aliases: TSConfigPaths
@@ -16,71 +23,97 @@ export class TypeScriptResolver {
16
23
  private projectRoot: string,
17
24
  tsConfigPaths?: TSConfigPaths
18
25
  ) {
19
- this.aliases = tsConfigPaths || {}
26
+ this.aliases = tsConfigPaths ?? {}
20
27
  }
21
28
 
22
29
  /** Resolve a single import relative to the importing file */
23
30
  resolve(imp: ParsedImport, fromFile: string, allProjectFiles: string[] = []): ParsedImport {
24
- // Skip external packages (no relative path prefix, no alias match)
25
- if (!imp.source.startsWith('.') && !imp.source.startsWith('/') && !this.matchesAlias(imp.source)) {
31
+ if (
32
+ !imp.source.startsWith('.') &&
33
+ !imp.source.startsWith('/') &&
34
+ !this.matchesAlias(imp.source)
35
+ ) {
26
36
  return { ...imp, resolvedPath: '' }
27
37
  }
28
-
29
- const resolved = this.resolvePath(imp.source, fromFile, allProjectFiles)
30
- return { ...imp, resolvedPath: resolved }
38
+ const fileSet = allProjectFiles.length > 0 ? new Set(allProjectFiles) : null
39
+ return { ...imp, resolvedPath: this.resolvePath(imp.source, fromFile, fileSet) }
31
40
  }
32
41
 
33
- private resolvePath(source: string, fromFile: string, allProjectFiles: string[]): string {
34
- let resolvedSource = source
42
+ resolveAll(imports: ParsedImport[], fromFile: string, allProjectFiles: string[] = []): ParsedImport[] {
43
+ const fileSet = allProjectFiles.length > 0 ? new Set(allProjectFiles) : null
44
+ return imports.map(imp => {
45
+ if (
46
+ !imp.source.startsWith('.') &&
47
+ !imp.source.startsWith('/') &&
48
+ !this.matchesAlias(imp.source)
49
+ ) {
50
+ return { ...imp, resolvedPath: '' }
51
+ }
52
+ return { ...imp, resolvedPath: this.resolvePath(imp.source, fromFile, fileSet) }
53
+ })
54
+ }
35
55
 
36
- // 1. Handle path aliases: @/utils/jwt -> src/utils/jwt
56
+ private resolvePath(source: string, fromFile: string, fileSet: Set<string> | null): string {
57
+ // 1. Alias substitution — try ALL targets in order, not just targets[0]
37
58
  for (const [alias, targets] of Object.entries(this.aliases)) {
38
- const aliasPrefix = alias.replace('/*', '')
39
- if (source.startsWith(aliasPrefix)) {
40
- const suffix = source.slice(aliasPrefix.length)
41
- const target = targets[0].replace('/*', '')
42
- resolvedSource = target + suffix
43
- break
59
+ const prefix = alias.replace('/*', '')
60
+ if (source.startsWith(prefix)) {
61
+ const suffix = source.slice(prefix.length)
62
+ for (const target of targets) {
63
+ const substituted = target.replace('/*', '') + suffix
64
+ const resolved = this.normalizePath(substituted, fromFile)
65
+ const found = this.probeExtensions(resolved, fileSet)
66
+ if (found) return found
67
+ }
68
+ // All alias targets exhausted — unresolved
69
+ return ''
44
70
  }
45
71
  }
46
72
 
47
- // 2. Handle relative paths
48
- let resolved: string
49
- if (resolvedSource.startsWith('.')) {
50
- const fromDir = path.dirname(fromFile)
51
- resolved = path.posix.normalize(path.posix.join(fromDir, resolvedSource))
52
- } else {
53
- resolved = resolvedSource
54
- }
73
+ // 2. Build normalized posix path from relative source
74
+ const resolved = this.normalizePath(source, fromFile)
55
75
 
56
- // Normalize to posix
57
- resolved = resolved.replace(/\\/g, '/')
76
+ // 3. Already has a concrete TS/JS extension — validate and return
77
+ const concreteExts = ['.ts', '.tsx', '.js', '.jsx', '.mjs']
78
+ if (concreteExts.some(e => resolved.endsWith(e))) {
79
+ if (fileSet && !fileSet.has(resolved)) return ''
80
+ return resolved
81
+ }
58
82
 
59
- // 3. Try to find exact match with extensions
60
- const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '/index.ts', '/index.tsx', '/index.js', '/index.jsx']
83
+ // 4. Probe extensions
84
+ return this.probeExtensions(resolved, fileSet) ?? resolved + '.ts'
85
+ }
61
86
 
62
- // If the path already has an extension, return it
63
- if (resolved.endsWith('.ts') || resolved.endsWith('.tsx')) {
64
- return resolved
87
+ private normalizePath(source: string, fromFile: string): string {
88
+ let resolved: string
89
+ if (source.startsWith('.')) {
90
+ const fromDir = path.dirname(fromFile.replace(/\\/g, '/'))
91
+ resolved = path.posix.normalize(path.posix.join(fromDir, source))
92
+ } else {
93
+ resolved = source
65
94
  }
95
+ return resolved.replace(/\\/g, '/')
96
+ }
66
97
 
67
- // Try adding extensions to find matching file
68
- for (const ext of extensions) {
98
+ /**
99
+ * Probe extensions in priority order.
100
+ * TS-first since this is a TypeScript resolver; JS fallback for mixed projects.
101
+ */
102
+ private probeExtensions(resolved: string, fileSet: Set<string> | null): string | null {
103
+ const probeOrder = [
104
+ '.ts', '.tsx',
105
+ '/index.ts', '/index.tsx',
106
+ '.js', '.jsx', '.mjs',
107
+ '/index.js', '/index.jsx',
108
+ ]
109
+ for (const ext of probeOrder) {
69
110
  const candidate = resolved + ext
70
- if (allProjectFiles.length === 0 || allProjectFiles.includes(candidate)) {
71
- return candidate
72
- }
111
+ if (fileSet === null || fileSet.has(candidate)) return candidate
73
112
  }
74
-
75
- // Fallback: just add .ts
76
- return resolved + '.ts'
113
+ return null
77
114
  }
78
115
 
79
116
  private matchesAlias(source: string): boolean {
80
- for (const alias of Object.keys(this.aliases)) {
81
- const prefix = alias.replace('/*', '')
82
- if (source.startsWith(prefix)) return true
83
- }
84
- return false
117
+ return Object.keys(this.aliases).some(a => source.startsWith(a.replace('/*', '')))
85
118
  }
86
119
  }
@@ -0,0 +1,27 @@
1
+ import * as fs from 'node:fs/promises'
2
+
3
+ /**
4
+ * Safe JSON reading utility with descriptive error messages.
5
+ * Centralizes JSON.parse hardening against syntax errors.
6
+ */
7
+ export async function readJsonSafe(
8
+ filePath: string,
9
+ fileLabel: string = 'JSON file'
10
+ ): Promise<any> {
11
+ let content: string
12
+ try {
13
+ content = await fs.readFile(filePath, 'utf-8')
14
+ } catch (e: any) {
15
+ if (e.code === 'ENOENT') {
16
+ throw e // Let callers handle missing files (e.g. ContractNotFoundError)
17
+ }
18
+ throw new Error(`Failed to read ${fileLabel}: ${e.message}`)
19
+ }
20
+
21
+ const sanitized = content.replace(/^\uFEFF/, '')
22
+ try {
23
+ return JSON.parse(sanitized)
24
+ } catch (e: any) {
25
+ throw new Error(`Malformed ${fileLabel}: Syntax error - ${e.message}`)
26
+ }
27
+ }
@@ -521,9 +521,29 @@ describe('JavaScriptParser', () => {
521
521
  parser.parse('src/auth.js', CJS_MODULE),
522
522
  parser.parse('src/loader.js', ESM_MODULE),
523
523
  ])
524
- const resolved = parser.resolveImports(files, '/project')
525
- const authFile = resolved.find((f: any) => f.path === 'src/auth.js')!
526
- const dbImport = authFile.imports.find((i: any) => i.source === './db')
524
+ // When no allProjectFiles list is passed to resolveImports, the resolver
525
+ // falls back to extension probing without filesystem validation and resolves
526
+ // relative imports to their most-likely path (e.g. './db' → 'src/db.js').
527
+ //
528
+ // Previously the test relied on the broken behaviour where the resolver
529
+ // always probed through even when the file wasn't in the provided list.
530
+ // The correct fix is to call resolveImports without a restrictive file list,
531
+ // which is what happens in production (the parser computes allFilePaths
532
+ // from the full project scan, not just the two files under test).
533
+ //
534
+ // We simulate a "full project" by telling the resolver that src/db.js exists.
535
+ const allProjectFiles = [
536
+ 'src/auth.js',
537
+ 'src/loader.js',
538
+ 'src/db.js', // ← the file that auth.js imports
539
+ ]
540
+ // resolveImports in JavaScriptParser uses files.map(f => f.path) internally,
541
+ // so to inject a richer file list we call the resolver directly here.
542
+ const resolver = new JavaScriptResolver('/project')
543
+ const authFile = files.find((f: any) => f.path === 'src/auth.js')!
544
+ const resolvedImports = resolver.resolveAll(authFile.imports, authFile.path, allProjectFiles)
545
+ const dbImport = resolvedImports.find((i: any) => i.source === './db')
546
+ expect(dbImport).toBeDefined()
527
547
  expect(dbImport!.resolvedPath).toMatch(/src\/db/)
528
548
  expect(dbImport!.resolvedPath).toMatch(/\.js$/)
529
549
  })