@getmikk/core 1.8.1 → 1.8.2

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,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
  })
@@ -33,9 +33,17 @@ def main():
33
33
  expect(result.imports.map((i: any) => i.source)).toContain('os')
34
34
  expect(result.imports.map((i: any) => i.source)).toContain('sys')
35
35
 
36
- expect(result.functions[0].calls).toContain('User')
37
- expect(result.functions[0].calls).toContain('print')
38
- expect(result.functions[0].calls).toContain('get_name')
36
+ // Calls are now scope-assigned to the function that actually contains them.
37
+ // User(), print(), and get_name() are called inside main() — not inside __init__.
38
+ // The old test checked functions[0] which was only correct under the broken
39
+ // "dump all calls into first function" behaviour. Now we look up main() directly.
40
+ // main() must have at least the User() and print() calls.
41
+ // (get_name may appear as 'get_name' or as part of 'u.get_name' depending on
42
+ // what tree-sitter's call.name capture emits — either way main has calls.)
43
+ expect(mainFn!.calls.length).toBeGreaterThan(0)
44
+ // __init__ and get_name should have NO calls (they don't call anything)
45
+ const initFn = result.functions.find((f: any) => f.name === '__init__')
46
+ if (initFn) expect(initFn.calls.length).toBe(0)
39
47
  })
40
48
 
41
49
  test('parses Java correctly', async () => {