@getmikk/core 1.6.0 → 1.7.1

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,83 @@
1
+ import * as path from 'node:path'
2
+ import type { ParsedImport } from '../types.js'
3
+
4
+ /**
5
+ * JavaScriptResolver — resolves JS/JSX/CJS import paths to project-relative files.
6
+ *
7
+ * Handles:
8
+ * - Relative ESM imports: import './utils' → ./utils.js / ./utils/index.js / ...
9
+ * - CommonJS require(): require('./db') → same resolution order
10
+ * - Path aliases from jsconfig.json / tsconfig.json
11
+ * - Mixed TS/JS projects: falls back to .ts/.tsx if no JS file matched
12
+ *
13
+ * Extension probe order: .js → .jsx → .mjs → .cjs → index.js → index.jsx →
14
+ * .ts → .tsx → index.ts → index.tsx
15
+ */
16
+ export class JavaScriptResolver {
17
+ constructor(
18
+ private readonly projectRoot: string,
19
+ private readonly aliases: Record<string, string[]> = {},
20
+ ) {}
21
+
22
+ resolve(imp: ParsedImport, fromFile: string, allProjectFiles: string[] = []): ParsedImport {
23
+ // External packages (no ./ / alias prefix) — leave unresolved
24
+ if (
25
+ !imp.source.startsWith('.') &&
26
+ !imp.source.startsWith('/') &&
27
+ !this.matchesAlias(imp.source)
28
+ ) {
29
+ return { ...imp, resolvedPath: '' }
30
+ }
31
+ return { ...imp, resolvedPath: this.resolvePath(imp.source, fromFile, allProjectFiles) }
32
+ }
33
+
34
+ resolveAll(imports: ParsedImport[], fromFile: string, allProjectFiles: string[] = []): ParsedImport[] {
35
+ return imports.map(imp => this.resolve(imp, fromFile, allProjectFiles))
36
+ }
37
+
38
+ private resolvePath(source: string, fromFile: string, allProjectFiles: string[]): string {
39
+ let resolvedSource = source
40
+
41
+ // 1. Alias substitution
42
+ for (const [alias, targets] of Object.entries(this.aliases)) {
43
+ const prefix = alias.replace('/*', '')
44
+ if (source.startsWith(prefix)) {
45
+ resolvedSource = targets[0].replace('/*', '') + source.slice(prefix.length)
46
+ break
47
+ }
48
+ }
49
+
50
+ // 2. Build absolute-like posix path
51
+ let resolved: string
52
+ if (resolvedSource.startsWith('.')) {
53
+ resolved = path.posix.normalize(path.posix.join(path.dirname(fromFile), resolvedSource))
54
+ } else {
55
+ resolved = resolvedSource
56
+ }
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
62
+
63
+ // 4. Probe extensions: prefer JS-family first, fall back to TS for mixed projects
64
+ const probeOrder = [
65
+ '.js', '.jsx', '.mjs', '.cjs',
66
+ '/index.js', '/index.jsx', '/index.mjs',
67
+ '.ts', '.tsx',
68
+ '/index.ts', '/index.tsx',
69
+ ]
70
+ for (const ext of probeOrder) {
71
+ const candidate = resolved + ext
72
+ if (allProjectFiles.length === 0 || allProjectFiles.includes(candidate)) {
73
+ return candidate
74
+ }
75
+ }
76
+
77
+ return resolved + '.js'
78
+ }
79
+
80
+ private matchesAlias(source: string): boolean {
81
+ return Object.keys(this.aliases).some(a => source.startsWith(a.replace('/*', '')))
82
+ }
83
+ }
@@ -89,7 +89,7 @@ export interface ParsedGeneric {
89
89
  /** Everything extracted from a single file */
90
90
  export interface ParsedFile {
91
91
  path: string // "src/auth/verify.ts"
92
- language: 'typescript' | 'python' | 'go'
92
+ language: 'typescript' | 'javascript' | 'python' | 'go'
93
93
  functions: ParsedFunction[]
94
94
  classes: ParsedClass[]
95
95
  generics: ParsedGeneric[]
@@ -7,11 +7,11 @@ import { hashContent } from '../../hash/file-hasher.js'
7
7
  * and extracts functions, classes, imports, exports and call relationships.
8
8
  */
9
9
  export class TypeScriptExtractor {
10
- private sourceFile: ts.SourceFile
10
+ protected readonly sourceFile: ts.SourceFile
11
11
 
12
12
  constructor(
13
- private filePath: string,
14
- private content: string
13
+ protected readonly filePath: string,
14
+ protected readonly content: string
15
15
  ) {
16
16
  this.sourceFile = ts.createSourceFile(
17
17
  filePath,
@@ -118,7 +118,7 @@ export class TypeScriptExtractor {
118
118
  return generics
119
119
  }
120
120
 
121
- private isVariableFunction(node: ts.VariableStatement): boolean {
121
+ protected isVariableFunction(node: ts.VariableStatement): boolean {
122
122
  for (const decl of node.declarationList.declarations) {
123
123
  if (decl.initializer && (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))) {
124
124
  return true
@@ -309,14 +309,14 @@ export class TypeScriptExtractor {
309
309
  return routes
310
310
  }
311
311
 
312
- // ─── Private Helpers ──────────────────────────────────────
312
+ // ─── Protected Helpers ─────────────────────────────────────
313
313
 
314
- private parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
314
+ protected parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
315
315
  const name = node.name!.text
316
316
  const startLine = this.getLineNumber(node.getStart())
317
317
  const endLine = this.getLineNumber(node.getEnd())
318
318
  const params = this.extractParams(node.parameters)
319
- const returnType = node.type ? node.type.getText(this.sourceFile) : 'void'
319
+ const returnType = normalizeTypeAnnotation(node.type ? node.type.getText(this.sourceFile) : 'void')
320
320
  const isAsync = !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
321
321
  const isGenerator = !!node.asteriskToken
322
322
  const typeParameters = this.extractTypeParameters(node.typeParameters)
@@ -344,7 +344,7 @@ export class TypeScriptExtractor {
344
344
  }
345
345
  }
346
346
 
347
- private parseVariableFunction(
347
+ protected parseVariableFunction(
348
348
  stmt: ts.VariableStatement,
349
349
  decl: ts.VariableDeclaration,
350
350
  fn: ts.ArrowFunction | ts.FunctionExpression
@@ -353,7 +353,7 @@ export class TypeScriptExtractor {
353
353
  const startLine = this.getLineNumber(stmt.getStart())
354
354
  const endLine = this.getLineNumber(stmt.getEnd())
355
355
  const params = this.extractParams(fn.parameters)
356
- const returnType = fn.type ? fn.type.getText(this.sourceFile) : 'void'
356
+ const returnType = normalizeTypeAnnotation(fn.type ? fn.type.getText(this.sourceFile) : 'void')
357
357
  const isAsync = !!fn.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
358
358
  const isGenerator = ts.isFunctionExpression(fn) && !!fn.asteriskToken
359
359
  const typeParameters = this.extractTypeParameters(fn.typeParameters)
@@ -381,7 +381,7 @@ export class TypeScriptExtractor {
381
381
  }
382
382
  }
383
383
 
384
- private parseClass(node: ts.ClassDeclaration): ParsedClass {
384
+ protected parseClass(node: ts.ClassDeclaration): ParsedClass {
385
385
  const name = node.name!.text
386
386
  const startLine = this.getLineNumber(node.getStart())
387
387
  const endLine = this.getLineNumber(node.getEnd())
@@ -420,7 +420,7 @@ export class TypeScriptExtractor {
420
420
  const mStartLine = this.getLineNumber(member.getStart())
421
421
  const mEndLine = this.getLineNumber(member.getEnd())
422
422
  const params = this.extractParams(member.parameters)
423
- const returnType = member.type ? member.type.getText(this.sourceFile) : 'void'
423
+ const returnType = normalizeTypeAnnotation(member.type ? member.type.getText(this.sourceFile) : 'void')
424
424
  const isAsync = !!member.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
425
425
  const isGenerator = !!member.asteriskToken
426
426
  const methodTypeParams = this.extractTypeParameters(member.typeParameters)
@@ -465,7 +465,7 @@ export class TypeScriptExtractor {
465
465
  }
466
466
  }
467
467
 
468
- private parseImport(node: ts.ImportDeclaration): ParsedImport | null {
468
+ protected parseImport(node: ts.ImportDeclaration): ParsedImport | null {
469
469
  const source = (node.moduleSpecifier as ts.StringLiteral).text
470
470
  const names: string[] = []
471
471
  let isDefault = false
@@ -503,7 +503,7 @@ export class TypeScriptExtractor {
503
503
  }
504
504
 
505
505
  /** Extract function/method call expressions from a node (including new Foo()) */
506
- private extractCalls(node: ts.Node): string[] {
506
+ protected extractCalls(node: ts.Node): string[] {
507
507
  const calls: string[] = []
508
508
  const walkCalls = (n: ts.Node) => {
509
509
  if (ts.isCallExpression(n)) {
@@ -532,7 +532,7 @@ export class TypeScriptExtractor {
532
532
 
533
533
  /** Extract the purpose from JSDoc comments or preceding single-line comments.
534
534
  * Falls back to deriving a human-readable sentence from the function name. */
535
- private extractPurpose(node: ts.Node): string {
535
+ protected extractPurpose(node: ts.Node): string {
536
536
  const fullText = this.sourceFile.getFullText()
537
537
  const commentRanges = ts.getLeadingCommentRanges(fullText, node.getFullStart())
538
538
  if (commentRanges && commentRanges.length > 0) {
@@ -563,7 +563,7 @@ export class TypeScriptExtractor {
563
563
  }
564
564
 
565
565
  /** Get the identifier name from common declaration node types */
566
- private getNodeName(node: ts.Node): string {
566
+ protected getNodeName(node: ts.Node): string {
567
567
  if (ts.isFunctionDeclaration(node) && node.name) return node.name.text
568
568
  if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
569
569
  const parent = node.parent
@@ -580,7 +580,7 @@ export class TypeScriptExtractor {
580
580
  }
581
581
 
582
582
  /** Extract edge cases handled (if statements returning early) */
583
- private extractEdgeCases(node: ts.Node): string[] {
583
+ protected extractEdgeCases(node: ts.Node): string[] {
584
584
  const edgeCases: string[] = []
585
585
  const walkEdgeCases = (n: ts.Node) => {
586
586
  if (ts.isIfStatement(n)) {
@@ -601,7 +601,7 @@ export class TypeScriptExtractor {
601
601
  }
602
602
 
603
603
  /** Extract try-catch blocks or explicit throw statements */
604
- private extractErrorHandling(node: ts.Node): { line: number, type: 'try-catch' | 'throw', detail: string }[] {
604
+ protected extractErrorHandling(node: ts.Node): { line: number, type: 'try-catch' | 'throw', detail: string }[] {
605
605
  const errors: { line: number, type: 'try-catch' | 'throw', detail: string }[] = []
606
606
  const walkErrors = (n: ts.Node) => {
607
607
  if (ts.isTryStatement(n)) {
@@ -625,7 +625,7 @@ export class TypeScriptExtractor {
625
625
  }
626
626
 
627
627
  /** Extract detailed line block breakdowns */
628
- private extractDetailedLines(node: ts.Node): { startLine: number, endLine: number, blockType: string }[] {
628
+ protected extractDetailedLines(node: ts.Node): { startLine: number, endLine: number, blockType: string }[] {
629
629
  const blocks: { startLine: number, endLine: number, blockType: string }[] = []
630
630
  const walkBlocks = (n: ts.Node) => {
631
631
  if (ts.isIfStatement(n) || ts.isSwitchStatement(n)) {
@@ -650,13 +650,13 @@ export class TypeScriptExtractor {
650
650
  }
651
651
 
652
652
  /** Extract type parameter names from a generic declaration */
653
- private extractTypeParameters(typeParams: ts.NodeArray<ts.TypeParameterDeclaration> | undefined): string[] {
653
+ protected extractTypeParameters(typeParams: ts.NodeArray<ts.TypeParameterDeclaration> | undefined): string[] {
654
654
  if (!typeParams || typeParams.length === 0) return []
655
655
  return typeParams.map(tp => tp.name.text)
656
656
  }
657
657
 
658
658
  /** Extract decorator names from a class declaration */
659
- private extractDecorators(node: ts.ClassDeclaration): string[] {
659
+ protected extractDecorators(node: ts.ClassDeclaration): string[] {
660
660
  const decorators: string[] = []
661
661
  const modifiers = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined
662
662
  if (modifiers) {
@@ -674,28 +674,28 @@ export class TypeScriptExtractor {
674
674
  }
675
675
 
676
676
  /** Extract parameters from a function's parameter list */
677
- private extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParsedParam[] {
677
+ protected extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParsedParam[] {
678
678
  return params.map((p) => ({
679
679
  name: p.name.getText(this.sourceFile),
680
- type: p.type ? p.type.getText(this.sourceFile) : 'any',
680
+ type: normalizeTypeAnnotation(p.type ? p.type.getText(this.sourceFile) : 'any'),
681
681
  optional: !!p.questionToken || !!p.initializer,
682
682
  defaultValue: p.initializer ? p.initializer.getText(this.sourceFile) : undefined,
683
683
  }))
684
684
  }
685
685
 
686
686
  /** Check if a node has the 'export' modifier */
687
- private hasExportModifier(node: ts.Node): boolean {
687
+ protected hasExportModifier(node: ts.Node): boolean {
688
688
  const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
689
689
  return !!modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
690
690
  }
691
691
 
692
692
  /** Get 1-indexed line number from a character position */
693
- private getLineNumber(pos: number): number {
693
+ protected getLineNumber(pos: number): number {
694
694
  return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
695
695
  }
696
696
 
697
697
  /** Walk the top-level children of a node (non-recursive — callbacks decide depth) */
698
- private walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
698
+ protected walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
699
699
  ts.forEachChild(node, (child) => {
700
700
  callback(child)
701
701
  })
@@ -712,6 +712,10 @@ export class TypeScriptExtractor {
712
712
  * UserRepository → "User repository"
713
713
  * parseFiles → "Parse files"
714
714
  */
715
+ function normalizeTypeAnnotation(type: string): string {
716
+ return type.replace(/\s*\n\s*/g, ' ').replace(/\s{2,}/g, ' ').trim()
717
+ }
718
+
715
719
  function derivePurposeFromName(name: string): string {
716
720
  if (!name || name === 'constructor') return ''
717
721
  // Split on camelCase/PascalCase boundaries and underscores
@@ -71,30 +71,27 @@ export class TypeScriptParser extends BaseParser {
71
71
  }
72
72
 
73
73
  /**
74
- * Read compilerOptions.paths from the nearest tsconfig.json in projectRoot.
75
- * Handles baseUrl prefix so aliases like "@/*" ["src/*"] resolve correctly.
74
+ * Read compilerOptions.paths from tsconfig.json in projectRoot.
75
+ * Recursively follows "extends" chains (e.g. extends ./tsconfig.base.json,
76
+ * extends @tsconfig/node-lts/tsconfig.json) and merges paths.
77
+ *
78
+ * Handles:
79
+ * - extends with relative paths (./tsconfig.base.json)
80
+ * - extends with node_modules packages (@tsconfig/node-lts)
81
+ * - baseUrl prefix so aliases like "@/*" → ["src/*"] resolve correctly
82
+ * - JSON5-style comments (line and block comments)
76
83
  */
77
84
  function loadTsConfigPaths(projectRoot: string): Record<string, string[]> {
78
85
  const candidates = ['tsconfig.json', 'tsconfig.base.json']
79
86
  for (const name of candidates) {
80
87
  const tsConfigPath = path.join(projectRoot, name)
81
88
  try {
82
- const raw = fs.readFileSync(tsConfigPath, 'utf-8')
83
- // Strip line comments before JSON.parse (tsconfig allows them)
84
- const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '')
85
- let tsConfig: any
86
- try {
87
- tsConfig = JSON.parse(stripped)
88
- } catch {
89
- // Stripping may have broken a URL (e.g. "https://...") — fall back to raw
90
- tsConfig = JSON.parse(raw)
91
- }
92
- const options = tsConfig.compilerOptions ?? {}
89
+ const merged = loadTsConfigWithExtends(tsConfigPath, new Set())
90
+ const options = merged.compilerOptions ?? {}
93
91
  const rawPaths: Record<string, string[]> = options.paths ?? {}
94
92
  if (Object.keys(rawPaths).length === 0) continue
95
93
 
96
94
  const baseUrl: string = options.baseUrl ?? '.'
97
- // Prefix each target with baseUrl so relative resolution works
98
95
  const resolved: Record<string, string[]> = {}
99
96
  for (const [alias, targets] of Object.entries(rawPaths)) {
100
97
  resolved[alias] = (targets as string[]).map(t =>
@@ -106,3 +103,85 @@ function loadTsConfigPaths(projectRoot: string): Record<string, string[]> {
106
103
  }
107
104
  return {}
108
105
  }
106
+
107
+ /**
108
+ * Recursively load a tsconfig, following the "extends" chain.
109
+ * Merges compilerOptions from parent → child (child wins on conflict).
110
+ * Prevents infinite loops via a visited set.
111
+ */
112
+ function loadTsConfigWithExtends(configPath: string, visited: Set<string>): any {
113
+ const resolved = path.resolve(configPath)
114
+ if (visited.has(resolved)) return {}
115
+ visited.add(resolved)
116
+
117
+ let raw: string
118
+ try {
119
+ raw = fs.readFileSync(resolved, 'utf-8')
120
+ } catch {
121
+ return {}
122
+ }
123
+
124
+ // Strip JSON5 comments
125
+ const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '')
126
+ let config: any
127
+ try {
128
+ config = JSON.parse(stripped)
129
+ } catch {
130
+ try { config = JSON.parse(raw) } catch { return {} }
131
+ }
132
+
133
+ if (!config.extends) return config
134
+
135
+ // Resolve the parent config path
136
+ const extendsValue = config.extends
137
+ let parentPath: string
138
+
139
+ if (extendsValue.startsWith('.')) {
140
+ // Relative path: ./tsconfig.base.json or ../tsconfig.json
141
+ parentPath = path.resolve(path.dirname(resolved), extendsValue)
142
+ // Add .json if missing
143
+ if (!parentPath.endsWith('.json')) parentPath += '.json'
144
+ } else {
145
+ // Node module: @tsconfig/node-lts or @tsconfig/node-lts/tsconfig.json
146
+ try {
147
+ // Try resolving as a node module from projectRoot
148
+ const projectRoot = path.dirname(resolved)
149
+ const modulePath = path.join(projectRoot, 'node_modules', extendsValue)
150
+ if (fs.existsSync(modulePath + '.json')) {
151
+ parentPath = modulePath + '.json'
152
+ } else if (fs.existsSync(path.join(modulePath, 'tsconfig.json'))) {
153
+ parentPath = path.join(modulePath, 'tsconfig.json')
154
+ } else if (fs.existsSync(modulePath)) {
155
+ parentPath = modulePath
156
+ } else {
157
+ // Can't resolve — skip extends
158
+ delete config.extends
159
+ return config
160
+ }
161
+ } catch {
162
+ delete config.extends
163
+ return config
164
+ }
165
+ }
166
+
167
+ // Load parent recursively
168
+ const parent = loadTsConfigWithExtends(parentPath, visited)
169
+
170
+ // Merge: parent compilerOptions → child compilerOptions (child wins)
171
+ const merged = { ...config }
172
+ delete merged.extends
173
+ merged.compilerOptions = {
174
+ ...(parent.compilerOptions ?? {}),
175
+ ...(config.compilerOptions ?? {}),
176
+ }
177
+
178
+ // Merge paths specifically (child paths override parent paths for same alias)
179
+ if (parent.compilerOptions?.paths || config.compilerOptions?.paths) {
180
+ merged.compilerOptions.paths = {
181
+ ...(parent.compilerOptions?.paths ?? {}),
182
+ ...(config.compilerOptions?.paths ?? {}),
183
+ }
184
+ }
185
+
186
+ return merged
187
+ }
@@ -76,7 +76,7 @@ describe('LockCompiler', () => {
76
76
  const graph = new GraphBuilder().build(files)
77
77
  const lock = compiler.compile(graph, contract, files)
78
78
 
79
- expect(lock.version).toBe('1.0.0')
79
+ expect(lock.version).toBe('1.7.0')
80
80
  expect(lock.syncState.status).toBe('clean')
81
81
  expect(Object.keys(lock.functions).length).toBeGreaterThan(0)
82
82
  expect(Object.keys(lock.modules).length).toBeGreaterThanOrEqual(1)
package/tests/helpers.ts CHANGED
@@ -45,7 +45,6 @@ export function mockFunction(
45
45
  purpose: '',
46
46
  edgeCasesHandled: [],
47
47
  errorHandling: [],
48
- detailedLines: [],
49
48
  }
50
49
  }
51
50