@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.
@@ -22,9 +22,10 @@ export class JavaScriptExtractor extends TypeScriptExtractor {
22
22
  /** ESM functions + module.exports-assigned functions */
23
23
  override extractFunctions(): ParsedFunction[] {
24
24
  const fns = super.extractFunctions()
25
- const seen = new Set(fns.map(f => f.name))
25
+ // Use id (which includes start line) to avoid false deduplication
26
+ const seen = new Set(fns.map(f => f.id))
26
27
  for (const fn of this.extractCommonJsFunctions()) {
27
- if (!seen.has(fn.name)) { fns.push(fn); seen.add(fn.name) }
28
+ if (!seen.has(fn.id)) { fns.push(fn); seen.add(fn.id) }
28
29
  }
29
30
  return fns
30
31
  }
@@ -42,9 +43,15 @@ export class JavaScriptExtractor extends TypeScriptExtractor {
42
43
  /** ESM exports + CommonJS module.exports / exports.x */
43
44
  override extractExports(): ParsedExport[] {
44
45
  const esm = super.extractExports()
45
- const seen = new Set(esm.map(e => e.name))
46
+ // Index by name; for default exports use type as secondary key to avoid
47
+ // a local function named 'default' from being incorrectly matched.
48
+ const seen = new Map(esm.map(e => [`${e.name}:${e.type}`, true]))
46
49
  for (const exp of this.extractCommonJsExports()) {
47
- if (!seen.has(exp.name)) { esm.push(exp); seen.add(exp.name) }
50
+ const key = `${exp.name}:${exp.type}`
51
+ if (!seen.has(key)) {
52
+ esm.push(exp)
53
+ seen.set(key, true)
54
+ }
48
55
  }
49
56
  return esm
50
57
  }
@@ -91,11 +98,20 @@ export class JavaScriptExtractor extends TypeScriptExtractor {
91
98
  private getRequireBindingNames(call: ts.CallExpression): string[] {
92
99
  const parent = call.parent
93
100
  if (!parent || !ts.isVariableDeclaration(parent)) return []
94
- // const { a, b } = require('...') → ['a', 'b']
101
+ // const { a: myA, b } = require('...') → ['a', 'b'] (use the SOURCE name, not the alias)
102
+ // The source name (propertyName) is what the module exports.
103
+ // The local alias (element.name) is only visible in this file.
95
104
  if (ts.isObjectBindingPattern(parent.name)) {
96
105
  return parent.name.elements
97
106
  .filter(e => ts.isIdentifier(e.name))
98
- .map(e => (e.name as ts.Identifier).text)
107
+ .map(e => {
108
+ // If there is a property name (the "a" in "a: myA"), use it.
109
+ // Otherwise the binding uses the same name for both sides.
110
+ if (e.propertyName && ts.isIdentifier(e.propertyName)) {
111
+ return e.propertyName.text
112
+ }
113
+ return (e.name as ts.Identifier).text
114
+ })
99
115
  }
100
116
  // const x = require('...') → ['x']
101
117
  if (ts.isIdentifier(parent.name)) return [parent.name.text]
@@ -5,6 +5,7 @@ import { JavaScriptExtractor } from './js-extractor.js'
5
5
  import { JavaScriptResolver } from './js-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
  * JavaScriptParser -- implements BaseParser for .js / .mjs / .cjs / .jsx files.
@@ -18,18 +19,25 @@ export class JavaScriptParser extends BaseParser {
18
19
  const extractor = new JavaScriptExtractor(filePath, content)
19
20
 
20
21
  const functions = extractor.extractFunctions()
21
- const classes = extractor.extractClasses()
22
- const generics = extractor.extractGenerics()
23
- const imports = extractor.extractImports()
24
- const exports = extractor.extractExports()
25
- const routes = extractor.extractRoutes()
22
+ const classes = extractor.extractClasses()
23
+ const generics = extractor.extractGenerics()
24
+ const imports = extractor.extractImports()
25
+ const exports = extractor.extractExports()
26
+ const routes = extractor.extractRoutes()
26
27
 
27
28
  // Cross-reference: CJS exports may mark a name exported even when the
28
29
  // declaration itself had no `export` keyword.
29
- const exportedNames = new Set(exports.map(e => e.name))
30
- for (const fn of functions) { if (!fn.isExported && exportedNames.has(fn.name)) fn.isExported = true }
31
- for (const cls of classes) { if (!cls.isExported && exportedNames.has(cls.name)) cls.isExported = true }
32
- for (const gen of generics) { if (!gen.isExported && exportedNames.has(gen.name)) gen.isExported = true }
30
+ //
31
+ // We only mark a symbol as exported when the export list contains an
32
+ // entry with BOTH a matching name AND a non-default type. This prevents
33
+ // `module.exports = function() {}` (which produces name='default', type='default')
34
+ // from accidentally marking an unrelated local function called 'default' as exported.
35
+ const exportedNonDefault = new Set(
36
+ exports.filter(e => e.type !== 'default').map(e => e.name)
37
+ )
38
+ for (const fn of functions) { if (!fn.isExported && exportedNonDefault.has(fn.name)) fn.isExported = true }
39
+ for (const cls of classes) { if (!cls.isExported && exportedNonDefault.has(cls.name)) cls.isExported = true }
40
+ for (const gen of generics) { if (!gen.isExported && exportedNonDefault.has(gen.name)) gen.isExported = true }
33
41
 
34
42
  return {
35
43
  path: filePath,
@@ -47,7 +55,10 @@ export class JavaScriptParser extends BaseParser {
47
55
 
48
56
  resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
49
57
  const aliases = loadAliases(projectRoot)
50
- const allFilePaths = files.map(f => f.path)
58
+ // Only pass the file list when it represents a reasonably complete scan.
59
+ // A sparse list (< MIN_FILES_FOR_COMPLETE_SCAN files) causes valid alias-resolved
60
+ // imports to return '' because the target file is not in the partial list.
61
+ const allFilePaths = files.length >= MIN_FILES_FOR_COMPLETE_SCAN ? files.map(f => f.path) : []
51
62
  const resolver = new JavaScriptResolver(projectRoot, aliases)
52
63
  return files.map(file => ({
53
64
  ...file,
@@ -62,8 +73,7 @@ export class JavaScriptParser extends BaseParser {
62
73
 
63
74
  /**
64
75
  * Load path aliases from jsconfig.json → tsconfig.json → tsconfig.base.json.
65
- * Strips JS/block comments before parsing (both formats allow them).
66
- * Falls back to raw content if comment-stripping breaks a URL.
76
+ * Strips JSON5 comments via the shared helper and falls back to raw content if parsing fails.
67
77
  * Returns {} when no config is found.
68
78
  */
69
79
  function loadAliases(projectRoot: string): Record<string, string[]> {
@@ -71,12 +81,9 @@ function loadAliases(projectRoot: string): Record<string, string[]> {
71
81
  const configPath = path.join(projectRoot, name)
72
82
  try {
73
83
  const raw = fs.readFileSync(configPath, 'utf-8')
74
- const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '')
75
- let config: any
76
- try { config = JSON.parse(stripped) }
77
- catch { config = JSON.parse(raw) } // URL stripping may have broken JSON
84
+ const config: any = parseJsonWithComments(raw)
78
85
 
79
- const options = config.compilerOptions ?? {}
86
+ const options = config.compilerOptions ?? {}
80
87
  const rawPaths: Record<string, string[]> = options.paths ?? {}
81
88
  if (Object.keys(rawPaths).length === 0) continue
82
89
 
@@ -7,11 +7,15 @@ import type { ParsedImport } from '../types.js'
7
7
  * Handles:
8
8
  * - Relative ESM imports: import './utils' → ./utils.js / ./utils/index.js / ...
9
9
  * - CommonJS require(): require('./db') → same resolution order
10
- * - Path aliases from jsconfig.json / tsconfig.json
10
+ * - Path aliases from jsconfig.json / tsconfig.json (all targets tried, not just first)
11
11
  * - Mixed TS/JS projects: falls back to .ts/.tsx if no JS file matched
12
12
  *
13
13
  * Extension probe order: .js → .jsx → .mjs → .cjs → index.js → index.jsx →
14
14
  * .ts → .tsx → index.ts → index.tsx
15
+ *
16
+ * Performance: allProjectFiles is converted to a Set internally for O(1) membership
17
+ * checks. Pass the same array every call — the Set is built per-call and is cheap
18
+ * for the sizes we deal with, or cache the Set externally and pass it directly.
15
19
  */
16
20
  export class JavaScriptResolver {
17
21
  constructor(
@@ -28,39 +32,77 @@ export class JavaScriptResolver {
28
32
  ) {
29
33
  return { ...imp, resolvedPath: '' }
30
34
  }
31
- return { ...imp, resolvedPath: this.resolvePath(imp.source, fromFile, allProjectFiles) }
35
+ // Build Set once per resolve call. For large repos callers should cache this.
36
+ const fileSet = allProjectFiles.length > 0 ? new Set(allProjectFiles) : null
37
+ return { ...imp, resolvedPath: this.resolvePath(imp.source, fromFile, fileSet) }
32
38
  }
33
39
 
34
40
  resolveAll(imports: ParsedImport[], fromFile: string, allProjectFiles: string[] = []): ParsedImport[] {
35
- return imports.map(imp => this.resolve(imp, fromFile, allProjectFiles))
41
+ // Build Set once for the entire batch — O(n) instead of O(n * imports * probes)
42
+ const fileSet = allProjectFiles.length > 0 ? new Set(allProjectFiles) : null
43
+ return imports.map(imp => {
44
+ if (
45
+ !imp.source.startsWith('.') &&
46
+ !imp.source.startsWith('/') &&
47
+ !this.matchesAlias(imp.source)
48
+ ) {
49
+ return { ...imp, resolvedPath: '' }
50
+ }
51
+ return { ...imp, resolvedPath: this.resolvePath(imp.source, fromFile, fileSet) }
52
+ })
36
53
  }
37
54
 
38
- private resolvePath(source: string, fromFile: string, allProjectFiles: string[]): string {
39
- let resolvedSource = source
40
-
41
- // 1. Alias substitution
55
+ private resolvePath(source: string, fromFile: string, fileSet: Set<string> | null): string {
56
+ // 1. Alias substitution — try ALL targets in order, not just targets[0]
42
57
  for (const [alias, targets] of Object.entries(this.aliases)) {
43
58
  const prefix = alias.replace('/*', '')
44
59
  if (source.startsWith(prefix)) {
45
- resolvedSource = targets[0].replace('/*', '') + source.slice(prefix.length)
46
- break
60
+ for (const target of targets) {
61
+ const substituted = target.replace('/*', '') + source.slice(prefix.length)
62
+ const resolved = this.normalizePath(substituted, fromFile)
63
+ const found = this.probeExtensions(resolved, fileSet)
64
+ if (found) return found
65
+ }
66
+ // All alias targets failed — fall through to return unresolved indicator
67
+ // rather than silently returning a path that doesn't exist
68
+ return ''
47
69
  }
48
70
  }
49
71
 
50
- // 2. Build absolute-like posix path
72
+ // 2. Build absolute-like posix path from relative source
73
+ const resolved = this.normalizePath(source, fromFile)
74
+
75
+ // 3. Already has a concrete extension — validate existence and return
76
+ const knownExts = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx']
77
+ if (knownExts.some(e => resolved.endsWith(e))) {
78
+ // If we have a file list, validate the path exists in it
79
+ if (fileSet && !fileSet.has(resolved)) return ''
80
+ return resolved
81
+ }
82
+
83
+ // 4. Probe extensions in priority order
84
+ return this.probeExtensions(resolved, fileSet) ?? ''
85
+ }
86
+
87
+ /** Normalize a source path to a posix-style project-relative path */
88
+ private normalizePath(source: string, fromFile: string): string {
51
89
  let resolved: string
52
- if (resolvedSource.startsWith('.')) {
53
- resolved = path.posix.normalize(path.posix.join(path.dirname(fromFile), resolvedSource))
90
+ if (source.startsWith('.')) {
91
+ resolved = path.posix.normalize(
92
+ path.posix.join(path.dirname(fromFile.replace(/\\/g, '/')), source)
93
+ )
54
94
  } else {
55
- resolved = resolvedSource
95
+ resolved = source
56
96
  }
57
- resolved = resolved.replace(/\\/g, '/')
58
-
59
- // 3. Already has a concrete extension — return as-is
60
- const knownExts = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx']
61
- if (knownExts.some(e => resolved.endsWith(e))) return resolved
97
+ return resolved.replace(/\\/g, '/')
98
+ }
62
99
 
63
- // 4. Probe extensions: prefer JS-family first, fall back to TS for mixed projects
100
+ /**
101
+ * Probe file extensions in priority order.
102
+ * Returns the first candidate that exists in the file set,
103
+ * or the first candidate if no file set is provided (legacy behaviour).
104
+ */
105
+ private probeExtensions(resolved: string, fileSet: Set<string> | null): string | null {
64
106
  const probeOrder = [
65
107
  '.js', '.jsx', '.mjs', '.cjs',
66
108
  '/index.js', '/index.jsx', '/index.mjs',
@@ -69,12 +111,11 @@ export class JavaScriptResolver {
69
111
  ]
70
112
  for (const ext of probeOrder) {
71
113
  const candidate = resolved + ext
72
- if (allProjectFiles.length === 0 || allProjectFiles.includes(candidate)) {
114
+ if (fileSet === null || fileSet.has(candidate)) {
73
115
  return candidate
74
116
  }
75
117
  }
76
-
77
- return resolved + '.js'
118
+ return null
78
119
  }
79
120
 
80
121
  private matchesAlias(source: string): boolean {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared parser utilities/constants.
3
+ * Keeps common thresholds and comment-stripping helpers in one place so
4
+ * JavaScript and TypeScript parsers behave consistently.
5
+ */
6
+
7
+ /** Files count threshold that signals a reasonably complete project scan. */
8
+ export const MIN_FILES_FOR_COMPLETE_SCAN = 10
9
+
10
+ /**
11
+ * Remove JSON5-style comments while preserving string literals.
12
+ * Handles single/double/backtick strings plus escaped characters.
13
+ */
14
+ export function stripJsonComments(raw: string): string {
15
+ let result = ''
16
+ let i = 0
17
+ let stringChar: string | null = null
18
+ let escaped = false
19
+
20
+ while (i < raw.length) {
21
+ const char = raw[i]
22
+ const next = i + 1 < raw.length ? raw[i + 1] : ''
23
+
24
+ if (stringChar) {
25
+ result += char
26
+ if (escaped) {
27
+ escaped = false
28
+ } else if (char === '\\') {
29
+ escaped = true
30
+ } else if (char === stringChar) {
31
+ stringChar = null
32
+ }
33
+ i += 1
34
+ continue
35
+ }
36
+
37
+ if (char === '"' || char === "'" || char === '`') {
38
+ stringChar = char
39
+ result += char
40
+ i += 1
41
+ continue
42
+ }
43
+
44
+ if (char === '/' && next === '*') {
45
+ i += 2
46
+ while (i < raw.length) {
47
+ if (raw[i] === '*' && raw[i + 1] === '/') {
48
+ i += 2
49
+ break
50
+ }
51
+ i += 1
52
+ }
53
+ continue
54
+ }
55
+
56
+ if (char === '/' && next === '/') {
57
+ i += 2
58
+ while (i < raw.length && raw[i] !== '\n' && raw[i] !== '\r') {
59
+ i += 1
60
+ }
61
+ continue
62
+ }
63
+
64
+ result += char
65
+ i += 1
66
+ }
67
+
68
+ return result
69
+ }
70
+
71
+ /**
72
+ * Parse JSON config files while tolerating JSON5 comments.
73
+ * Falls back to the raw content if comment stripping breaks URLs.
74
+ */
75
+ export function parseJsonWithComments<T = any>(raw: string): T {
76
+ const stripped = stripJsonComments(raw)
77
+ try {
78
+ return JSON.parse(stripped)
79
+ } catch {
80
+ return JSON.parse(raw)
81
+ }
82
+ }