@getmikk/core 2.0.14 → 2.0.16

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.
Files changed (64) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -1
  3. package/src/analysis/type-flow.ts +1 -1
  4. package/src/cache/incremental-cache.ts +86 -80
  5. package/src/contract/contract-reader.ts +1 -0
  6. package/src/contract/lock-compiler.ts +95 -13
  7. package/src/contract/schema.ts +2 -0
  8. package/src/error-handler.ts +2 -1
  9. package/src/graph/cluster-detector.ts +2 -4
  10. package/src/graph/dead-code-detector.ts +303 -117
  11. package/src/graph/graph-builder.ts +21 -161
  12. package/src/graph/impact-analyzer.ts +1 -0
  13. package/src/graph/index.ts +2 -0
  14. package/src/graph/rich-function-index.ts +1080 -0
  15. package/src/graph/symbol-table.ts +252 -0
  16. package/src/hash/hash-store.ts +1 -0
  17. package/src/index.ts +2 -0
  18. package/src/parser/base-extractor.ts +19 -0
  19. package/src/parser/boundary-checker.ts +31 -12
  20. package/src/parser/error-recovery.ts +5 -4
  21. package/src/parser/function-body-extractor.ts +248 -0
  22. package/src/parser/go/go-extractor.ts +249 -676
  23. package/src/parser/index.ts +132 -318
  24. package/src/parser/language-registry.ts +57 -0
  25. package/src/parser/oxc-parser.ts +166 -28
  26. package/src/parser/oxc-resolver.ts +179 -11
  27. package/src/parser/parser-constants.ts +1 -0
  28. package/src/parser/rust/rust-extractor.ts +109 -0
  29. package/src/parser/tree-sitter/parser.ts +369 -62
  30. package/src/parser/tree-sitter/queries.ts +106 -10
  31. package/src/parser/types.ts +20 -1
  32. package/src/search/bm25.ts +21 -8
  33. package/src/search/direct-search.ts +472 -0
  34. package/src/search/embedding-provider.ts +249 -0
  35. package/src/search/index.ts +12 -0
  36. package/src/search/semantic-search.ts +435 -0
  37. package/src/utils/artifact-transaction.ts +1 -0
  38. package/src/utils/atomic-write.ts +1 -0
  39. package/src/utils/errors.ts +89 -4
  40. package/src/utils/fs.ts +104 -50
  41. package/src/utils/json.ts +1 -0
  42. package/src/utils/language-registry.ts +84 -6
  43. package/src/utils/path.ts +26 -0
  44. package/tests/dead-code.test.ts +3 -2
  45. package/tests/direct-search.test.ts +435 -0
  46. package/tests/error-recovery.test.ts +143 -0
  47. package/tests/fixtures/simple-api/src/index.ts +1 -1
  48. package/tests/go-parser.test.ts +19 -335
  49. package/tests/js-parser.test.ts +18 -1089
  50. package/tests/language-registry-all.test.ts +276 -0
  51. package/tests/language-registry.test.ts +6 -4
  52. package/tests/parse-diagnostics.test.ts +9 -96
  53. package/tests/parser.test.ts +42 -771
  54. package/tests/polyglot-parser.test.ts +117 -0
  55. package/tests/rich-function-index.test.ts +703 -0
  56. package/tests/tree-sitter-parser.test.ts +108 -80
  57. package/tests/ts-parser.test.ts +8 -8
  58. package/tests/verification.test.ts +175 -0
  59. package/src/parser/base-parser.ts +0 -16
  60. package/src/parser/go/go-parser.ts +0 -43
  61. package/src/parser/javascript/js-extractor.ts +0 -278
  62. package/src/parser/javascript/js-parser.ts +0 -101
  63. package/src/parser/typescript/ts-extractor.ts +0 -447
  64. package/src/parser/typescript/ts-parser.ts +0 -36
@@ -0,0 +1,252 @@
1
+ import type { ParsedFile, ParsedImport } from '../parser/types.js'
2
+ import { normalizePathQuiet } from '../utils/path.js'
3
+
4
+ export interface SymbolDefinition {
5
+ id: string
6
+ name: string
7
+ type: 'function' | 'class' | 'variable' | 'interface' | 'type' | 'enum'
8
+ file: string
9
+ isExported: boolean
10
+ }
11
+
12
+ export interface ImportAlias {
13
+ localName: string
14
+ exportedName: string
15
+ sourcePath: string
16
+ }
17
+
18
+ /**
19
+ * GlobalSymbolTable — The brain of Mikk's semantic reasoning.
20
+ * Indexes all exported symbols and provides resolution logic for calls.
21
+ * Supports: named imports, default imports, aliased imports, local symbols, and fuzzy matching.
22
+ *
23
+ * Case sensitivity: Symbol names are case-sensitive for accurate resolution.
24
+ * Fallback to case-insensitive matching only when no exact match exists.
25
+ */
26
+ export class GlobalSymbolTable {
27
+ private exportsByFile = new Map<string, Map<string, SymbolDefinition>>()
28
+ private symbolsByName = new Map<string, SymbolDefinition[]>()
29
+ private importAliases: ImportAlias[] = []
30
+ private reexportsByFile = new Map<string, Map<string, string>>()
31
+ private resolveCache = new Map<string, string | null>()
32
+ private resolveCacheOrder: string[] = []
33
+ private importsByFile = new Map<string, ParsedImport[]>()
34
+
35
+ private static readonly MAX_RESOLVE_CACHE_SIZE = 10000
36
+
37
+ private cacheResolve(key: string, value: string | null): void {
38
+ if (this.resolveCache.size >= GlobalSymbolTable.MAX_RESOLVE_CACHE_SIZE) {
39
+ const oldestKey = this.resolveCacheOrder.shift()
40
+ if (oldestKey) this.resolveCache.delete(oldestKey)
41
+ }
42
+ this.resolveCache.set(key, value)
43
+ this.resolveCacheOrder.push(key)
44
+ }
45
+
46
+ register(file: ParsedFile): void {
47
+ const fileExports = new Map<string, SymbolDefinition>()
48
+ const filePath = normalizePathQuiet(file.path)
49
+ const fileReexports = new Map<string, string>()
50
+
51
+ const registerSymbol = (name: string, type: SymbolDefinition['type'], id: string, isExported: boolean) => {
52
+ const def: SymbolDefinition = { id, name, type, file: filePath, isExported }
53
+
54
+ if (isExported) {
55
+ fileExports.set(name, def)
56
+ }
57
+
58
+ const byName = this.symbolsByName.get(name) ?? []
59
+ byName.push(def)
60
+ this.symbolsByName.set(name, byName)
61
+ }
62
+
63
+ for (const fn of file.functions) registerSymbol(fn.name, 'function', fn.id, fn.isExported)
64
+ for (const cls of file.classes ?? []) {
65
+ registerSymbol(cls.name, 'class', cls.id, cls.isExported)
66
+ for (const m of cls.methods) {
67
+ const fullName = `${cls.name}.${m.name}`
68
+ registerSymbol(fullName, 'function', m.id, false)
69
+ const byName = this.symbolsByName.get(m.name) ?? []
70
+ byName.push({ id: m.id, name: m.name, type: 'function', file: filePath, isExported: false })
71
+ this.symbolsByName.set(m.name, byName)
72
+ }
73
+ }
74
+ for (const gen of file.generics ?? []) {
75
+ const validType = gen.type === 'interface' || gen.type === 'type' ? gen.type : 'type'
76
+ registerSymbol(gen.name, validType, gen.id, gen.isExported)
77
+ }
78
+ for (const v of file.variables ?? []) registerSymbol(v.name, 'variable', v.id, v.isExported)
79
+
80
+ for (const imp of file.imports) {
81
+ if (!imp.resolvedPath || imp.isDynamic) continue
82
+ const resolvedPath = normalizePathQuiet(imp.resolvedPath)
83
+ for (const spec of imp.specifiers ?? []) {
84
+ if (spec.imported && spec.local && spec.imported !== spec.local) {
85
+ this.importAliases.push({
86
+ localName: spec.local,
87
+ exportedName: spec.imported,
88
+ sourcePath: resolvedPath,
89
+ })
90
+ }
91
+ }
92
+ }
93
+
94
+ for (const re of file.reexports ?? []) {
95
+ if (re.sourceResolved) {
96
+ fileReexports.set(re.name, normalizePathQuiet(re.sourceResolved))
97
+ }
98
+ }
99
+
100
+ this.exportsByFile.set(filePath, fileExports)
101
+ if (fileReexports.size > 0) {
102
+ this.reexportsByFile.set(filePath, fileReexports)
103
+ }
104
+ const fileImports = file.imports.filter(i => i.resolvedPath && !i.isDynamic)
105
+ this.importsByFile.set(filePath, fileImports)
106
+ }
107
+
108
+ resolve(callName: string, contextFile: string, imports: ParsedImport[]): string | null {
109
+ const hasDot = callName.includes('.')
110
+ const receiver = hasDot ? callName.split('.')[0] : null
111
+ const simpleName = hasDot ? callName.split('.').pop()! : callName
112
+ const normalizedContextFile = normalizePathQuiet(contextFile)
113
+
114
+ const cacheKey = `${normalizedContextFile}:${callName}`
115
+ if (this.resolveCache.has(cacheKey)) {
116
+ return this.resolveCache.get(cacheKey)!
117
+ }
118
+
119
+ const importsToUse = this.importsByFile.get(normalizedContextFile) ?? imports.filter(i => i.resolvedPath && !i.isDynamic)
120
+ let result: string | null = null
121
+
122
+ for (const imp of importsToUse) {
123
+ if (!imp.resolvedPath) continue
124
+ const resolvedPath = normalizePathQuiet(imp.resolvedPath)
125
+
126
+ if (imp.isDynamic) continue
127
+
128
+ if (hasDot && receiver) {
129
+ if (imp.isDefault && imp.names.some(n => n === receiver || n.toLowerCase() === receiver.toLowerCase())) {
130
+ const target = this.getExport(resolvedPath, simpleName)
131
+ if (target) { result = target.id; break }
132
+ const methodTarget = this.getMethodByClassCaseInsensitive(resolvedPath, receiver, simpleName)
133
+ if (methodTarget) { result = methodTarget; break }
134
+ }
135
+
136
+ const alias = this.importAliases.find(
137
+ a => a.localName === receiver && a.sourcePath === resolvedPath
138
+ )
139
+ if (alias) {
140
+ const target = this.getExport(resolvedPath, alias.exportedName)
141
+ if (target) { result = target.id; break }
142
+ }
143
+
144
+ const receiverLower = receiver.toLowerCase()
145
+ const aliasLower = this.importAliases.find(
146
+ a => a.localName.toLowerCase() === receiverLower && a.sourcePath === resolvedPath
147
+ )
148
+ if (aliasLower) {
149
+ const target = this.getExport(resolvedPath, aliasLower.exportedName)
150
+ if (target) { result = target.id; break }
151
+ }
152
+
153
+ const methodTarget = this.getMethodByClassCaseInsensitive(resolvedPath, receiver, simpleName)
154
+ if (methodTarget) { result = methodTarget; break }
155
+ }
156
+
157
+ const matchedLocal = imp.names.find(n =>
158
+ n === callName ||
159
+ n === simpleName ||
160
+ n.toLowerCase() === callName.toLowerCase() ||
161
+ n.toLowerCase() === simpleName.toLowerCase() ||
162
+ this.importAliases.some(a => a.localName === n && (a.exportedName === callName || a.exportedName === simpleName)) ||
163
+ this.importAliases.some(a => a.localName.toLowerCase() === n.toLowerCase() && (a.exportedName.toLowerCase() === callName.toLowerCase() || a.exportedName.toLowerCase() === simpleName.toLowerCase()))
164
+ )
165
+ if (matchedLocal) {
166
+ const alias = this.importAliases.find(a => a.localName === matchedLocal)
167
+ const exportedName = alias ? alias.exportedName : simpleName
168
+ const target = this.getExport(resolvedPath, exportedName)
169
+ if (target) { result = target.id; break }
170
+ }
171
+ }
172
+
173
+ if (!result) {
174
+ const localTarget = this.getExport(normalizedContextFile, callName)
175
+ if (localTarget) { result = localTarget.id }
176
+ }
177
+
178
+ if (!result) {
179
+ const globalMatches = this.symbolsByName.get(callName) ?? this.symbolsByName.get(simpleName)
180
+ if (globalMatches?.length === 1) {
181
+ result = globalMatches[0].id
182
+ }
183
+ }
184
+
185
+ if (!result) {
186
+ const lowerCallName = callName.toLowerCase()
187
+ const lowerSimpleName = simpleName.toLowerCase()
188
+ const lowerGlobalMatches = this.symbolsByName.get(lowerCallName) ?? this.symbolsByName.get(lowerSimpleName)
189
+ if (lowerGlobalMatches?.length === 1) {
190
+ result = lowerGlobalMatches[0].id
191
+ }
192
+ }
193
+
194
+ if (!result && hasDot && receiver) {
195
+ const methodTarget = this.getMethodGlobally(receiver, simpleName)
196
+ if (methodTarget) { result = methodTarget }
197
+ }
198
+
199
+ this.cacheResolve(cacheKey, result)
200
+ return result
201
+ }
202
+
203
+ private getExport(filePath: string, name: string, visited: Set<string> = new Set()): SymbolDefinition | undefined {
204
+ if (visited.has(filePath + ':' + name)) return undefined
205
+ visited.add(filePath + ':' + name)
206
+
207
+ const exports = this.exportsByFile.get(filePath)
208
+ if (exports) {
209
+ const def = exports.get(name) ?? exports.get(name.toLowerCase())
210
+ if (def) return def
211
+ }
212
+
213
+ const reexports = this.reexportsByFile.get(filePath)
214
+ if (reexports) {
215
+ const reexportPath = reexports.get(name) ?? reexports.get(name.toLowerCase())
216
+ if (reexportPath) {
217
+ return this.getExport(reexportPath, name, visited)
218
+ }
219
+ }
220
+
221
+ return undefined
222
+ }
223
+
224
+ private getMethodByClassCaseInsensitive(filePath: string, className: string, methodName: string): string | null {
225
+ const exports = this.exportsByFile.get(filePath)
226
+ if (!exports) return null
227
+
228
+ for (const [name, def] of exports) {
229
+ if (def.type === 'class' && name.toLowerCase() === className.toLowerCase()) {
230
+ const methodFullName = `${name}.${methodName}`
231
+ const method = this.symbolsByName.get(methodFullName)
232
+ if (method?.length === 1) return method[0].id
233
+
234
+ const methodLowerName = `${name.toLowerCase()}.${methodName.toLowerCase()}`
235
+ const methodLower = this.symbolsByName.get(methodLowerName)
236
+ if (methodLower?.length === 1) return methodLower[0].id
237
+ }
238
+ }
239
+ return null
240
+ }
241
+
242
+ private getMethodGlobally(className: string, methodName: string): string | null {
243
+ for (const [name, defs] of this.symbolsByName) {
244
+ if (defs[0]?.type === 'class' && name.toLowerCase() === className.toLowerCase()) {
245
+ const methodFullName = `${name}.${methodName}`
246
+ const method = this.symbolsByName.get(methodFullName)
247
+ if (method?.length === 1) return method[0].id
248
+ }
249
+ }
250
+ return null
251
+ }
252
+ }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1
2
  import * as path from 'node:path'
2
3
  import * as fs from 'node:fs'
3
4
  import Database from 'better-sqlite3'
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from './hash/index.js'
8
8
  export * from './search/index.js'
9
9
  export * from './cache/index.js'
10
10
  export * from './security/index.js'
11
+ export * from './analysis/index.js'
11
12
  export * from './utils/errors.js'
12
13
  export * from './utils/logger.js'
13
14
  export { MikkError, ErrorHandler, ErrorBuilder, ErrorCategory, FileSystemError, ModuleLoadError, GraphError, TokenBudgetError, ValidationError, createDefaultErrorListener, createFileNotFoundError, createFileTooLargeError, createPermissionDeniedError, createModuleNotFoundError, createModuleLoadFailedError, createGraphBuildFailedError, createNodeNotFoundError, createTokenBudgetExceededError, createValidationError, isMikkError, getRootCause, toMikkError } from './error-handler.js'
@@ -18,6 +19,7 @@ export { minimatch } from './utils/minimatch.js'
18
19
  export { scoreFunctions, findFuzzyMatches, levenshtein, splitCamelCase, extractKeywords } from './utils/fuzzy-match.js'
19
20
  export { writeFileAtomic, writeJsonAtomic } from './utils/atomic-write.js'
20
21
  export type { AtomicWriteOptions } from './utils/atomic-write.js'
22
+ export { normalizeSlashes, normalizePath, normalizePathQuiet, getPathKey, pathsEqual, isSubPath } from './utils/path.js'
21
23
  export {
22
24
  runArtifactWriteTransaction,
23
25
  recoverArtifactWriteTransactions,
@@ -0,0 +1,19 @@
1
+ import type { ParsedFile } from './types.js';
2
+
3
+ /**
4
+ * Abstract base class for language-specific metadata extraction.
5
+ * Extractors can use different engines (Tree-Sitter, OXC, etc.) internally.
6
+ */
7
+ export abstract class BaseExtractor {
8
+ /**
9
+ * Main entry point to parse and extract metadata from a file
10
+ */
11
+ abstract extract(filePath: string, content: string): Promise<ParsedFile>;
12
+
13
+ /**
14
+ * Resolve imports for a list of files (optional, default to simple pass-through)
15
+ */
16
+ async resolveImports(files: ParsedFile[], _projectRoot: string): Promise<ParsedFile[]> {
17
+ return files;
18
+ }
19
+ }
@@ -53,21 +53,40 @@ function parseConstraint(constraint: string): ParsedRule | null {
53
53
  export class BoundaryChecker {
54
54
  private rules: ParsedRule[]
55
55
  private moduleNames: Map<string, string>
56
+ private fileModuleMap: Map<string, string>
56
57
 
57
58
  constructor(private contract: MikkContract, private lock: MikkLock) {
58
59
  this.rules = contract.declared.constraints.map(parseConstraint).filter((r): r is ParsedRule => r !== null)
59
60
  this.moduleNames = new Map(contract.declared.modules.map(m => [m.id, m.name]))
61
+
62
+ this.fileModuleMap = new Map()
63
+ for (const [filePath, fileData] of Object.entries(this.lock.files)) {
64
+ if (fileData.moduleId && fileData.moduleId !== 'unknown') {
65
+ this.fileModuleMap.set(filePath.toLowerCase(), fileData.moduleId)
66
+ }
67
+ }
68
+ }
69
+
70
+ private getModuleId(fn: any): string {
71
+ if (fn.moduleId && fn.moduleId !== 'unknown') return fn.moduleId
72
+ const normalized = (fn.file ?? '').replace(/\\/g, '/').toLowerCase()
73
+ return this.fileModuleMap.get(normalized) ?? 'unknown'
60
74
  }
61
75
 
62
76
  check(): BoundaryCheckResult {
63
77
  const violations: BoundaryViolation[] = []
78
+ const fnIndex = (this.lock as any).fnIndex ?? []
64
79
 
65
80
  for (const fn of Object.values(this.lock.functions)) {
66
- if (fn.moduleId === 'unknown') continue
67
- for (const calleeId of fn.calls) {
81
+ const fnModuleId = this.getModuleId(fn)
82
+ if (fnModuleId === 'unknown') continue
83
+ const calls = (fn.calls ?? []).map((c: any) => typeof c === 'number' ? fnIndex[c] : c).filter(Boolean)
84
+ for (const calleeId of calls) {
68
85
  const callee = this.lock.functions[calleeId]
69
- if (!callee || callee.moduleId === 'unknown' || fn.moduleId === callee.moduleId) continue
70
- const v = this.checkCall(fn, callee)
86
+ if (!callee) continue
87
+ const calleeModuleId = this.getModuleId(callee)
88
+ if (calleeModuleId === 'unknown' || fnModuleId === calleeModuleId) continue
89
+ const v = this.checkCall(fn, callee, fnModuleId, calleeModuleId)
71
90
  if (v) violations.push(v)
72
91
  }
73
92
  }
@@ -97,21 +116,21 @@ export class BoundaryChecker {
97
116
  const errorCount = unique.filter(v => v.severity === 'error').length
98
117
  const warnCount = unique.filter(v => v.severity === 'warning').length
99
118
  const summary = unique.length === 0
100
- ? `All module boundaries respected (${fnCount} functions, ${fileCount} files checked)`
101
- : `${errorCount} boundary error(s), ${warnCount} warning(s) found`
119
+ ? `All module boundaries respected (${fnCount} fns, ${fileCount} files checked)`
120
+ : `${errorCount} error(s), ${warnCount} warning(s) found`
102
121
 
103
122
  return { pass: errorCount === 0, violations: unique, summary }
104
123
  }
105
124
 
106
- private checkCall(caller: MikkLockFunction, callee: MikkLockFunction): BoundaryViolation | null {
125
+ private checkCall(caller: any, callee: any, callerModuleId: string, calleeModuleId: string): BoundaryViolation | null {
107
126
  for (const rule of this.rules) {
108
- if (rule.fromModuleId !== caller.moduleId) continue
127
+ if (rule.fromModuleId !== callerModuleId) continue
109
128
  const forbidden = rule.type === 'isolated' ? true
110
- : rule.type === 'deny' ? rule.toModuleIds.includes(callee.moduleId)
111
- : !rule.toModuleIds.includes(callee.moduleId)
129
+ : rule.type === 'deny' ? rule.toModuleIds.includes(calleeModuleId)
130
+ : !rule.toModuleIds.includes(calleeModuleId)
112
131
  if (forbidden) return {
113
- from: { functionId: caller.id, functionName: caller.name, file: caller.file, moduleId: caller.moduleId, moduleName: this.moduleNames.get(caller.moduleId) ?? caller.moduleId },
114
- to: { functionId: callee.id, functionName: callee.name, file: callee.file, moduleId: callee.moduleId, moduleName: this.moduleNames.get(callee.moduleId) ?? callee.moduleId },
132
+ from: { functionId: caller.id, functionName: caller.name, file: caller.file, moduleId: callerModuleId, moduleName: this.moduleNames.get(callerModuleId) ?? callerModuleId },
133
+ to: { functionId: callee.id, functionName: callee.name, file: callee.file, moduleId: calleeModuleId, moduleName: this.moduleNames.get(calleeModuleId) ?? calleeModuleId },
115
134
  rule: rule.raw, severity: 'error'
116
135
  }
117
136
  }
@@ -1,6 +1,7 @@
1
- import type { ParsedFile, ParsedFunction, ParsedClass, ParsedImport, ParsedParam, CallExpression } from './types.js'
1
+ import type { ParsedFile, ParsedFunction, ParsedClass, ParsedImport, ParsedParam } from './types.js'
2
2
  import * as path from 'node:path'
3
3
  import { hashContent } from '../hash/file-hasher.js'
4
+ import { toParsedFileLanguage, type RegistryLanguage } from '../utils/language-registry.js'
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Error Recovery Engine — graceful degradation when parsing fails
@@ -64,7 +65,7 @@ export class ErrorRecoveryEngine {
64
65
  strategy: 'regex-recovery',
65
66
  parsed: {
66
67
  path: filePath,
67
- language: language as ParsedFile['language'],
68
+ language: toParsedFileLanguage(language as RegistryLanguage),
68
69
  hash: hashContent(content),
69
70
  parsedAt: Date.now(),
70
71
  functions,
@@ -92,7 +93,7 @@ export class ErrorRecoveryEngine {
92
93
  strategy: 'minimal-fallback',
93
94
  parsed: {
94
95
  path: filePath,
95
- language: language as ParsedFile['language'],
96
+ language: toParsedFileLanguage(language as RegistryLanguage),
96
97
  hash: hashContent(content),
97
98
  parsedAt: Date.now(),
98
99
  functions: [],
@@ -510,7 +511,7 @@ export class ErrorRecoveryEngine {
510
511
  lines: string[],
511
512
  functions: ParsedFunction[],
512
513
  classes: ParsedClass[],
513
- imports: ParsedImport[]
514
+ _imports: ParsedImport[]
514
515
  ): void {
515
516
  const funcPatterns = [
516
517
  /function\s+(\w+)\s*\(/,
@@ -0,0 +1,248 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import type { RichFunction } from '../graph/rich-function-index.js'
4
+
5
+ export interface FunctionBody {
6
+ id: string
7
+ name: string
8
+ file: string
9
+ startLine: number
10
+ endLine: number
11
+ body: string
12
+ fullSource: string
13
+ isComplete: boolean
14
+ trimmedLines: number
15
+ }
16
+
17
+ export interface BodyExtractOptions {
18
+ maxLines?: number
19
+ includeComments?: boolean
20
+ includeImports?: boolean
21
+ contextLines?: number
22
+ }
23
+
24
+ export class FunctionBodyExtractor {
25
+ private cache: Map<string, string> = new Map()
26
+ private bodyCache: Map<string, FunctionBody> = new Map()
27
+
28
+ async extractBody(fn: RichFunction, options: BodyExtractOptions = {}): Promise<FunctionBody | null> {
29
+ const cacheKey = `${fn.id}:${fn.startLine}:${fn.endLine}`
30
+
31
+ if (this.bodyCache.has(cacheKey)) {
32
+ return this.bodyCache.get(cacheKey)!
33
+ }
34
+
35
+ const { maxLines = 500, contextLines = 0 } = options
36
+
37
+ const source = await this.readSource(fn.file)
38
+ if (!source) {
39
+ return null
40
+ }
41
+
42
+ const lines = source.split('\n')
43
+
44
+ const startLine = Math.max(0, fn.startLine - 1 - contextLines)
45
+ const endLine = Math.min(lines.length, fn.endLine + contextLines)
46
+
47
+ const bodyLines = lines.slice(startLine, endLine)
48
+ const body = bodyLines.join('\n')
49
+
50
+ const isComplete = fn.endLine <= lines.length && bodyLines.length >= fn.endLine - fn.startLine
51
+
52
+ const result: FunctionBody = {
53
+ id: fn.id,
54
+ name: fn.name,
55
+ file: fn.file,
56
+ startLine: fn.startLine,
57
+ endLine: fn.endLine,
58
+ body,
59
+ fullSource: source,
60
+ isComplete,
61
+ trimmedLines: lines.length - endLine,
62
+ }
63
+
64
+ this.bodyCache.set(cacheKey, result)
65
+ return result
66
+ }
67
+
68
+ async extractBodies(fns: RichFunction[], options: BodyExtractOptions = {}): Promise<Map<string, FunctionBody>> {
69
+ const results = new Map<string, FunctionBody>()
70
+
71
+ for (const fn of fns) {
72
+ const body = await this.extractBody(fn, options)
73
+ if (body) {
74
+ results.set(fn.id, body)
75
+ }
76
+ }
77
+
78
+ return results
79
+ }
80
+
81
+ async extractBodiesByIds(ids: string[], getFn: (id: string) => RichFunction | undefined, options: BodyExtractOptions = {}): Promise<Map<string, FunctionBody>> {
82
+ const results = new Map<string, FunctionBody>()
83
+
84
+ const fns = ids.map(id => getFn(id)).filter(Boolean) as RichFunction[]
85
+ const bodies = await this.extractBodies(fns, options)
86
+
87
+ for (const [id, body] of bodies) {
88
+ results.set(id, body)
89
+ }
90
+
91
+ return results
92
+ }
93
+
94
+ private async readSource(filePath: string): Promise<string | null> {
95
+ if (this.cache.has(filePath)) {
96
+ return this.cache.get(filePath)!
97
+ }
98
+
99
+ if (!existsSync(filePath)) {
100
+ return null
101
+ }
102
+
103
+ try {
104
+ const content = await readFile(filePath, 'utf-8')
105
+ this.cache.set(filePath, content)
106
+ return content
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+
112
+ clearCache(): void {
113
+ this.cache.clear()
114
+ this.bodyCache.clear()
115
+ }
116
+
117
+ getCachedCount(): number {
118
+ return this.cache.size
119
+ }
120
+
121
+ getBodyCacheCount(): number {
122
+ return this.bodyCache.size
123
+ }
124
+ }
125
+
126
+ export interface ExtractResult {
127
+ success: boolean
128
+ body?: string
129
+ signature?: string
130
+ params?: string
131
+ returnType?: string
132
+ docComment?: string
133
+ error?: string
134
+ }
135
+
136
+ export async function extractFunction(
137
+ filePath: string,
138
+ functionName: string,
139
+ options: BodyExtractOptions = {}
140
+ ): Promise<ExtractResult> {
141
+ if (!existsSync(filePath)) {
142
+ return { success: false, error: 'File not found' }
143
+ }
144
+
145
+ try {
146
+ const content = await readFile(filePath, 'utf-8')
147
+ const lines = content.split('\n')
148
+
149
+ let startLine = -1
150
+ let endLine = -1
151
+ let braceCount = 0
152
+ let inFunction = false
153
+ let foundOpening = false
154
+
155
+ for (let i = 0; i < lines.length; i++) {
156
+ const line = lines[i]
157
+
158
+ if (!inFunction && (line.includes(`function ${functionName}`) || line.includes(`const ${functionName}`) || line.includes(`${functionName}(`) || line.includes(`async ${functionName}`))) {
159
+ if (line.includes(`function ${functionName}`) || line.includes(`async function ${functionName}`) || line.match(new RegExp(`(const|let|var)\\s+${functionName}\\s*=`)) || line.match(new RegExp(`${functionName}\\s*\\(`))) {
160
+ inFunction = true
161
+ startLine = i
162
+ }
163
+ }
164
+
165
+ if (inFunction) {
166
+ for (const char of line) {
167
+ if (char === '{') {
168
+ braceCount++
169
+ foundOpening = true
170
+ } else if (char === '}') {
171
+ braceCount--
172
+ }
173
+ }
174
+
175
+ if (foundOpening && braceCount === 0) {
176
+ endLine = i + 1
177
+ break
178
+ }
179
+ }
180
+ }
181
+
182
+ if (startLine === -1 || endLine === -1) {
183
+ return { success: false, error: 'Function not found' }
184
+ }
185
+
186
+ const bodyLines = lines.slice(startLine, endLine)
187
+ const body = bodyLines.join('\n')
188
+
189
+ const maxLines = options.maxLines || 500
190
+ const trimmedBody = bodyLines.length > maxLines
191
+ ? bodyLines.slice(0, maxLines).join('\n') + '\n// ... (truncated)'
192
+ : body
193
+
194
+ return {
195
+ success: true,
196
+ body: trimmedBody,
197
+ signature: extractSignature(bodyLines[0] || ''),
198
+ docComment: extractDocComment(lines, startLine),
199
+ }
200
+ } catch (err: any) {
201
+ return { success: false, error: err.message }
202
+ }
203
+ }
204
+
205
+ function extractSignature(line: string): string {
206
+ const match = line.match(/(?:async\s+)?(?:function\s+)?(?:\w+\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?/)
207
+ return match ? match[0] : line.trim()
208
+ }
209
+
210
+ function extractDocComment(lines: string[], startLine: number): string | undefined {
211
+ if (startLine === 0) return undefined
212
+
213
+ const prevLine = lines[startLine - 1]
214
+ if (prevLine && (prevLine.includes('/**') || prevLine.includes('///') || prevLine.startsWith('//'))) {
215
+ const commentLines: string[] = []
216
+ let i = startLine - 1
217
+
218
+ while (i >= 0) {
219
+ const line = lines[i]
220
+ if (line.includes('/**') || line.includes("'''")) {
221
+ commentLines.unshift(line)
222
+ break
223
+ } else if (line.trim().startsWith('*') || line.trim().startsWith('//')) {
224
+ commentLines.unshift(line)
225
+ } else {
226
+ break
227
+ }
228
+ i--
229
+ }
230
+
231
+ return commentLines.join('\n') || undefined
232
+ }
233
+
234
+ return undefined
235
+ }
236
+
237
+ export function trimBody(body: string, maxLines: number): string {
238
+ const lines = body.split('\n')
239
+ if (lines.length <= maxLines) return body
240
+
241
+ return lines.slice(0, maxLines).join('\n') + '\n// ...'
242
+ }
243
+
244
+ export function getBodyPreview(body: string, maxChars: number = 200): string {
245
+ const firstLine = body.split('\n')[0]
246
+ if (firstLine.length <= maxChars) return firstLine
247
+ return firstLine.slice(0, maxChars) + '...'
248
+ }