@getmikk/core 1.8.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmikk/core",
3
- "version": "1.8.2",
3
+ "version": "1.8.3",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -325,18 +325,19 @@ export class LockCompiler {
325
325
  for (const file of parsedFiles) {
326
326
  const moduleId = this.findModule(file.path, contract.declared.modules)
327
327
 
328
- // Collect file-level imports from the graph's import edges
329
- const outEdges = graph.outEdges.get(file.path) || []
330
- const importedFiles = outEdges
331
- .filter(e => e.type === 'imports')
332
- .map(e => e.target)
333
-
328
+ // Collect file-level imports from the parsed file info directly
329
+ // to include both source and resolvedPath for unresolved analysis.
330
+ const imports = file.imports.map(imp => ({
331
+ source: imp.source,
332
+ resolvedPath: imp.resolvedPath || undefined,
333
+ }))
334
+
334
335
  result[file.path] = {
335
336
  path: file.path,
336
337
  hash: file.hash,
337
338
  moduleId: moduleId || 'unknown',
338
339
  lastModified: new Date(file.parsedAt).toISOString(),
339
- ...(importedFiles.length > 0 ? { imports: importedFiles } : {}),
340
+ ...(imports.length > 0 ? { imports } : {}),
340
341
  }
341
342
  }
342
343
 
@@ -83,12 +83,17 @@ export const MikkLockModuleSchema = z.object({
83
83
  fragmentPath: z.string(),
84
84
  })
85
85
 
86
+ export const MikkLockImportSchema = z.object({
87
+ source: z.string(),
88
+ resolvedPath: z.string().optional(),
89
+ })
90
+
86
91
  export const MikkLockFileSchema = z.object({
87
92
  path: z.string(),
88
93
  hash: z.string(),
89
94
  moduleId: z.string(),
90
95
  lastModified: z.string(),
91
- imports: z.array(z.string()).optional(),
96
+ imports: z.array(MikkLockImportSchema).optional(),
92
97
  })
93
98
 
94
99
  export const MikkLockClassSchema = z.object({
@@ -195,9 +195,31 @@ export class DeadCodeDetector {
195
195
  }
196
196
 
197
197
  private isCalledByExportedInSameFile(fn: MikkLock['functions'][string]): boolean {
198
- for (const callerId of fn.calledBy) {
199
- const caller = this.lock.functions[callerId]
200
- if (caller && caller.isExported && caller.file === fn.file) return true
198
+ // Multi-pass transitive liveness: propagate liveness through the full calledBy
199
+ // chain until no new live functions are discovered. A single-hop check misses
200
+ // patterns like: exportedFn internalA internalB (internalB is still live).
201
+ const file = fn.file
202
+ const visited = new Set<string>()
203
+ const queue: string[] = [fn.id]
204
+
205
+ while (queue.length > 0) {
206
+ const currentId = queue.pop()!
207
+ if (visited.has(currentId)) continue
208
+ visited.add(currentId)
209
+
210
+ const current = this.lock.functions[currentId]
211
+ if (!current) continue
212
+
213
+ for (const callerId of current.calledBy) {
214
+ if (visited.has(callerId)) continue
215
+ const caller = this.lock.functions[callerId]
216
+ if (!caller) continue
217
+ // Only follow the chain within the same file
218
+ if (caller.file !== file) continue
219
+ // Found a live exported caller in the same file — the original fn is live
220
+ if (caller.isExported) return true
221
+ queue.push(callerId)
222
+ }
201
223
  }
202
224
  return false
203
225
  }
@@ -213,9 +235,9 @@ export class DeadCodeDetector {
213
235
  * high — none of the above: safe to remove.
214
236
  */
215
237
  private inferConfidence(fn: MikkLock['functions'][string]): DeadCodeConfidence {
238
+ if (DYNAMIC_USAGE_PATTERNS.some(p => p.test(fn.name))) return 'low'
216
239
  if (fn.calledBy.length > 0) return 'medium'
217
240
  if (this.filesWithUnresolvedImports.has(fn.file)) return 'medium'
218
- if (DYNAMIC_USAGE_PATTERNS.some(p => p.test(fn.name))) return 'low'
219
241
  return 'high'
220
242
  }
221
243
 
@@ -240,7 +262,7 @@ export class DeadCodeDetector {
240
262
  if (!this.lock.files) return result
241
263
 
242
264
  for (const [filePath, fileInfo] of Object.entries(this.lock.files)) {
243
- const imports = (fileInfo as any).imports ?? []
265
+ const imports = fileInfo.imports ?? []
244
266
  for (const imp of imports) {
245
267
  if (!imp.resolvedPath || imp.resolvedPath === '') {
246
268
  result.add(filePath)
@@ -143,7 +143,11 @@ export class GraphBuilder {
143
143
  endLine: gen.endLine,
144
144
  isExported: gen.isExported,
145
145
  purpose: gen.purpose,
146
- hash: gen.type,
146
+ // Store the declaration kind (interface|type|const) separately from the
147
+ // content hash. gen.hash is the actual content hash; gen.type is the
148
+ // declaration kind string — they are different things and must not be mixed.
149
+ hash: gen.hash ?? undefined,
150
+ genericKind: gen.type,
147
151
  },
148
152
  })
149
153
  }
@@ -232,7 +236,11 @@ export class GraphBuilder {
232
236
  const receiver = hasDot ? call.split('.')[0] : null
233
237
 
234
238
  // --- 1. Named import exact match ---
235
- const namedId = importedNames.get(call) ?? importedNames.get(simpleName)
239
+ // Only fall back to simpleName when there is no dotted receiver.
240
+ // If call = "jwt.verify", falling back to importedNames.get("verify") could
241
+ // match a completely different import named "verify" — wrong target, high
242
+ // confidence false-positive. Only strip the receiver when there is none.
243
+ const namedId = importedNames.get(call) ?? (receiver === null ? importedNames.get(simpleName) : undefined)
236
244
  if (namedId && graph.nodes.has(namedId)) {
237
245
  this.pushEdge(graph, edgeKeys, {
238
246
  source: fn.id,
@@ -294,7 +302,7 @@ export class GraphBuilder {
294
302
  }
295
303
  }
296
304
 
297
- /** Containment edges: file → function, file → class, class → method */
305
+ /** Containment edges: file → function, file → class, class → method, file → generic */
298
306
  private addContainmentEdges(
299
307
  graph: DependencyGraph,
300
308
  file: ParsedFile,
@@ -321,6 +329,15 @@ export class GraphBuilder {
321
329
  })
322
330
  }
323
331
  }
332
+ // Generic declarations (interfaces, type aliases, top-level constants) are also
333
+ // contained by their file — needed so dead-code and impact analysis can trace them.
334
+ for (const gen of file.generics ?? []) {
335
+ this.pushEdge(graph, edgeKeys, {
336
+ source: file.path,
337
+ target: gen.id,
338
+ type: 'contains',
339
+ })
340
+ }
324
341
  }
325
342
 
326
343
  // -------------------------------------------------------------------------
@@ -27,19 +27,23 @@ export class ImpactAnalyzer {
27
27
 
28
28
  const queue: { id: string; depth: number; confidence: number }[] =
29
29
  changedNodeIds.map(id => ({ id, depth: 0, confidence: 1.0 }))
30
+ // Use an index pointer instead of queue.shift() to avoid O(n) cost per dequeue.
31
+ let queueHead = 0
30
32
  let maxDepth = 0
31
33
 
32
34
  const changedSet = new Set(changedNodeIds)
33
35
 
34
- // Collect module IDs of the changed nodes
35
- const changedModules = new Set<string | undefined>()
36
+ // Collect module IDs of the changed nodes — filter out undefined so that
37
+ // nodes without a moduleId don't accidentally match every other unmoduled node
38
+ // and cause everything to appear "same module".
39
+ const changedModules = new Set<string>()
36
40
  for (const id of changedNodeIds) {
37
41
  const node = this.graph.nodes.get(id)
38
- if (node) changedModules.add(node.moduleId)
42
+ if (node?.moduleId) changedModules.add(node.moduleId)
39
43
  }
40
44
 
41
- while (queue.length > 0) {
42
- const { id: current, depth, confidence: pathConf } = queue.shift()!
45
+ while (queueHead < queue.length) {
46
+ const { id: current, depth, confidence: pathConf } = queue[queueHead++]
43
47
  if (visited.has(current)) continue
44
48
  visited.add(current)
45
49
  depthMap.set(current, depth)
@@ -74,7 +78,9 @@ export class ImpactAnalyzer {
74
78
  if (!node) continue
75
79
 
76
80
  const depth = depthMap.get(id) ?? 999
77
- const crossesBoundary = !changedModules.has(node.moduleId)
81
+ // A node crosses a module boundary when its module differs from ALL changed modules.
82
+ // If the node has no moduleId, treat it as crossing a boundary (unknown module ≠ known).
83
+ const crossesBoundary = !node.moduleId || !changedModules.has(node.moduleId)
78
84
 
79
85
  const risk: RiskLevel =
80
86
  depth === 1 && crossesBoundary ? 'critical' :
@@ -19,6 +19,7 @@ export interface GraphNode {
19
19
  isAsync?: boolean
20
20
  hash?: string
21
21
  purpose?: string
22
+ genericKind?: string
22
23
  params?: { name: string; type: string; optional?: boolean }[]
23
24
  returnType?: string
24
25
  edgeCasesHandled?: string[]
@@ -74,7 +74,9 @@ export class BoundaryChecker {
74
74
 
75
75
  for (const file of Object.values(this.lock.files)) {
76
76
  if (file.moduleId === 'unknown' || !file.imports?.length) continue
77
- for (const importedPath of file.imports) {
77
+ for (const imp of file.imports) {
78
+ const importedPath = imp.resolvedPath
79
+ if (!importedPath) continue
78
80
  const importedFile = this.lock.files[importedPath]
79
81
  if (!importedFile || importedFile.moduleId === 'unknown' || file.moduleId === importedFile.moduleId) continue
80
82
  const v = this.checkFileImport(file, importedFile)
@@ -54,6 +54,7 @@ const ROUTE_PATTERNS: RoutePattern[] = [
54
54
  */
55
55
  export class GoExtractor {
56
56
  private readonly lines: string[]
57
+ private cachedFunctions: ReturnType<typeof this.scanFunctions> | null = null
57
58
 
58
59
  constructor(
59
60
  private readonly filePath: string,
@@ -174,6 +175,7 @@ export class GoExtractor {
174
175
  endLine: number
175
176
  purpose: string
176
177
  }> {
178
+ if (this.cachedFunctions) return this.cachedFunctions
177
179
  const results: Array<{
178
180
  name: string
179
181
  receiverType?: string
@@ -237,6 +239,7 @@ export class GoExtractor {
237
239
  i = bodyEnd + 1
238
240
  }
239
241
 
242
+ this.cachedFunctions = results
240
243
  return results
241
244
  }
242
245
 
@@ -547,11 +550,17 @@ function findBodyBounds(lines: string[], startLine: number): { bodyStart: number
547
550
 
548
551
  if (ch === '/' && next === '/') { inLineComment = true; break }
549
552
  if (ch === '/' && next === '*') { inBlockComment = true; j++; continue }
550
- if (ch === '"' || ch === '`' || ch === '\'') {
553
+ if (ch === '"' || ch === '`') {
551
554
  inString = true
552
555
  stringChar = ch
553
556
  continue
554
557
  }
558
+ if (ch === '\'') {
559
+ // Go rune literal: consume exactly one character (or escape) then close
560
+ if (next === '\\') j += 3 // '\n' or '\x00' etc.
561
+ else j += 2 // 'a'
562
+ continue
563
+ }
555
564
 
556
565
  if (ch === '{') {
557
566
  if (bodyStart === -1) bodyStart = i
@@ -142,6 +142,7 @@ function assignCallsToFunctions(
142
142
  export class TreeSitterParser extends BaseParser {
143
143
  private parser: any = null
144
144
  private languages = new Map<string, any>()
145
+ private nameCounter = new Map<string, number>()
145
146
 
146
147
  getSupportedExtensions(): string[] {
147
148
  return ['.py', '.java', '.c', '.cpp', '.cc', '.h', '.hpp', '.cs', '.go', '.rs', '.php', '.rb']
@@ -155,6 +156,7 @@ export class TreeSitterParser extends BaseParser {
155
156
  }
156
157
 
157
158
  async parse(filePath: string, content: string): Promise<ParsedFile> {
159
+ this.nameCounter.clear()
158
160
  await this.init()
159
161
  const ext = path.extname(filePath).toLowerCase()
160
162
  const config = await this.getLanguageConfig(ext)
@@ -215,11 +217,12 @@ export class TreeSitterParser extends BaseParser {
215
217
  const startLine = defNode.startPosition.row + 1
216
218
  const endLine = defNode.endPosition.row + 1
217
219
  const nodeText = defNode.text ?? ''
220
+ const count = (this.nameCounter.get(fnName) ?? 0) + 1
221
+ this.nameCounter.set(fnName, count)
218
222
 
219
- // Unique ID: include start line to handle overloads and same-name scoped functions
220
- let fnId = `fn:${filePath}:${fnName}:${startLine}`
223
+ // Unique ID: use stable format with counter for collisions
224
+ let fnId = count === 1 ? `fn:${filePath}:${fnName}` : `fn:${filePath}:${fnName}#${count}`
221
225
  if (seenFnIds.has(fnId)) {
222
- // Extremely rare duplicate — skip rather than corrupt
223
226
  continue
224
227
  }
225
228
  seenFnIds.add(fnId)
@@ -269,7 +272,7 @@ export class TreeSitterParser extends BaseParser {
269
272
  const startLine = defNode.startPosition.row + 1
270
273
  const endLine = defNode.endPosition.row + 1
271
274
  const nodeText = defNode.text ?? ''
272
- const clsId = `cls:${filePath}:${clsName}:${startLine}`
275
+ const clsId = `class:${filePath}:${clsName}` // consistent with ts-extractor
273
276
 
274
277
  if (!classesMap.has(clsId)) {
275
278
  classesMap.set(clsId, {
@@ -300,7 +303,7 @@ export class TreeSitterParser extends BaseParser {
300
303
  endLine: lineCount || 1,
301
304
  params: [],
302
305
  returnType: 'void',
303
- isExported: true,
306
+ isExported: false, // Don't export the synthetic module function
304
307
  isAsync: false,
305
308
  calls: Array.from(new Set(unassignedCalls)),
306
309
  hash: '',
@@ -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,9 +351,7 @@ export class TypeScriptExtractor {
324
351
  const bodyText = node.getText(this.sourceFile)
325
352
 
326
353
  return {
327
- // Include startLine in the ID to prevent collision between overload signatures
328
- // and same-named functions in different scopes (e.g. two `init` declarations).
329
- id: `fn:${this.filePath}:${name}:${startLine}`,
354
+ id,
330
355
  name,
331
356
  file: this.filePath,
332
357
  startLine,
@@ -352,7 +377,8 @@ export class TypeScriptExtractor {
352
377
  fn: ts.ArrowFunction | ts.FunctionExpression
353
378
  ): ParsedFunction {
354
379
  const name = (decl.name as ts.Identifier).text
355
- const startLine = this.getLineNumber(stmt.getStart())
380
+ const id = this.allocateFnId(name)
381
+ const startLine = this.getLineNumber(stmt.getStart(this.sourceFile))
356
382
  const endLine = this.getLineNumber(stmt.getEnd())
357
383
  const params = this.extractParams(fn.parameters)
358
384
  const returnType = normalizeTypeAnnotation(fn.type ? fn.type.getText(this.sourceFile) : 'void')
@@ -363,9 +389,7 @@ export class TypeScriptExtractor {
363
389
  const bodyText = stmt.getText(this.sourceFile)
364
390
 
365
391
  return {
366
- // Include startLine to prevent collision between same-named const arrow functions
367
- // at different scopes (e.g. two `handler` declarations in different blocks).
368
- id: `fn:${this.filePath}:${name}:${startLine}`,
392
+ id,
369
393
  name,
370
394
  file: this.filePath,
371
395
  startLine,
@@ -387,7 +411,7 @@ export class TypeScriptExtractor {
387
411
 
388
412
  protected parseClass(node: ts.ClassDeclaration): ParsedClass {
389
413
  const name = node.name!.text
390
- const startLine = this.getLineNumber(node.getStart())
414
+ const startLine = this.getLineNumber(node.getStart(this.sourceFile))
391
415
  const endLine = this.getLineNumber(node.getEnd())
392
416
  const methods: ParsedFunction[] = []
393
417
  const decorators = this.extractDecorators(node)
@@ -396,15 +420,16 @@ export class TypeScriptExtractor {
396
420
  for (const member of node.members) {
397
421
  if (ts.isConstructorDeclaration(member)) {
398
422
  // Track class constructors as methods
399
- const mStartLine = this.getLineNumber(member.getStart())
423
+ const mStartLine = this.getLineNumber(member.getStart(this.sourceFile))
400
424
  const mEndLine = this.getLineNumber(member.getEnd())
401
425
  const params = this.extractParams(member.parameters)
402
426
  const calls = this.extractCalls(member)
403
427
  const bodyText = member.getText(this.sourceFile)
428
+ const methodName = `${name}.constructor`
404
429
 
405
430
  methods.push({
406
- id: `fn:${this.filePath}:${name}.constructor:${mStartLine}`,
407
- name: `${name}.constructor`,
431
+ id: this.allocateFnId(methodName),
432
+ name: methodName,
408
433
  file: this.filePath,
409
434
  startLine: mStartLine,
410
435
  endLine: mEndLine,
@@ -420,8 +445,8 @@ export class TypeScriptExtractor {
420
445
  detailedLines: this.extractDetailedLines(member),
421
446
  })
422
447
  } else if (ts.isMethodDeclaration(member) && member.name) {
423
- const methodName = member.name.getText(this.sourceFile)
424
- const mStartLine = this.getLineNumber(member.getStart())
448
+ const methodName = `${name}.${member.name.getText(this.sourceFile)}`
449
+ const mStartLine = this.getLineNumber(member.getStart(this.sourceFile))
425
450
  const mEndLine = this.getLineNumber(member.getEnd())
426
451
  const params = this.extractParams(member.parameters)
427
452
  const returnType = normalizeTypeAnnotation(member.type ? member.type.getText(this.sourceFile) : 'void')
@@ -432,8 +457,8 @@ export class TypeScriptExtractor {
432
457
  const bodyText = member.getText(this.sourceFile)
433
458
 
434
459
  methods.push({
435
- id: `fn:${this.filePath}:${name}.${methodName}:${mStartLine}`,
436
- name: `${name}.${methodName}`,
460
+ id: this.allocateFnId(methodName),
461
+ name: methodName,
437
462
  file: this.filePath,
438
463
  startLine: mStartLine,
439
464
  endLine: mEndLine,
@@ -475,6 +500,9 @@ export class TypeScriptExtractor {
475
500
  let isDefault = false
476
501
 
477
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
+
478
506
  // import Foo from './module' (default import)
479
507
  if (node.importClause.name) {
480
508
  names.push(node.importClause.name.text)
@@ -484,7 +512,10 @@ export class TypeScriptExtractor {
484
512
  if (node.importClause.namedBindings) {
485
513
  if (ts.isNamedImports(node.importClause.namedBindings)) {
486
514
  for (const element of node.importClause.namedBindings.elements) {
487
- 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
+ }
488
519
  }
489
520
  }
490
521
  // import * as foo from './module'
@@ -494,9 +525,6 @@ export class TypeScriptExtractor {
494
525
  }
495
526
  }
496
527
 
497
- // Skip type-only imports
498
- if (node.importClause?.isTypeOnly) return null
499
-
500
528
  return {
501
529
  source,
502
530
  resolvedPath: '', // Filled in by resolver
@@ -506,9 +534,21 @@ export class TypeScriptExtractor {
506
534
  }
507
535
  }
508
536
 
509
- /** 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
+ */
510
545
  protected extractCalls(node: ts.Node): string[] {
511
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
+
512
552
  const walkCalls = (n: ts.Node) => {
513
553
  if (ts.isCallExpression(n)) {
514
554
  const callee = n.expression
@@ -530,7 +570,7 @@ export class TypeScriptExtractor {
530
570
  }
531
571
  ts.forEachChild(n, walkCalls)
532
572
  }
533
- ts.forEachChild(node, walkCalls)
573
+ ts.forEachChild(bodyNode, walkCalls)
534
574
  return [...new Set(calls)] // deduplicate
535
575
  }
536
576
 
@@ -545,9 +585,6 @@ export class TypeScriptExtractor {
545
585
  const comment = fullText.slice(range.pos, range.end)
546
586
  let clean = ''
547
587
  if (comment.startsWith('/**') || comment.startsWith('/*')) {
548
- // Strip only the comment delimiters (/* ** */) NOT arbitrary slashes.
549
- // Using /[\/\*]/g was wrong — it removes slashes inside URL paths and
550
- // regex literals embedded in doc comments (e.g. "/api/users" → "apiusers").
551
588
  clean = comment
552
589
  .replace(/^\/\*+/, '') // remove leading /* or /**
553
590
  .replace(/\*+\/$/, '') // remove trailing */
@@ -563,7 +600,6 @@ export class TypeScriptExtractor {
563
600
  if (clean) meaningfulLines.push(clean)
564
601
  }
565
602
 
566
- // Return the first meaningful line in JSDoc, the first line is the summary.
567
603
  const fromComment = meaningfulLines.length > 0 ? meaningfulLines[0].split('\n')[0].trim() : ''
568
604
  if (fromComment) return fromComment
569
605
  }
@@ -585,7 +621,7 @@ export class TypeScriptExtractor {
585
621
  if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) return node.name.text
586
622
  if (ts.isConstructorDeclaration(node)) return 'constructor'
587
623
  if ((ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
588
- return (node as any).name.text
624
+ return (node as ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.ClassDeclaration).name!.text
589
625
  }
590
626
  return ''
591
627
  }
@@ -617,14 +653,14 @@ export class TypeScriptExtractor {
617
653
  const walkErrors = (n: ts.Node) => {
618
654
  if (ts.isTryStatement(n)) {
619
655
  errors.push({
620
- line: this.getLineNumber(n.getStart()),
656
+ line: this.getLineNumber(n.getStart(this.sourceFile)),
621
657
  type: 'try-catch',
622
658
  detail: 'try-catch block'
623
659
  })
624
660
  }
625
661
  if (ts.isThrowStatement(n)) {
626
662
  errors.push({
627
- line: this.getLineNumber(n.getStart()),
663
+ line: this.getLineNumber(n.getStart(this.sourceFile)),
628
664
  type: 'throw',
629
665
  detail: n.expression ? n.expression.getText(this.sourceFile) : 'throw error'
630
666
  })
@@ -641,18 +677,16 @@ export class TypeScriptExtractor {
641
677
  const walkBlocks = (n: ts.Node) => {
642
678
  if (ts.isIfStatement(n) || ts.isSwitchStatement(n)) {
643
679
  blocks.push({
644
- startLine: this.getLineNumber(n.getStart()),
680
+ startLine: this.getLineNumber(n.getStart(this.sourceFile)),
645
681
  endLine: this.getLineNumber(n.getEnd()),
646
682
  blockType: 'ControlFlow'
647
683
  })
648
684
  } else if (ts.isForStatement(n) || ts.isWhileStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n)) {
649
685
  blocks.push({
650
- startLine: this.getLineNumber(n.getStart()),
686
+ startLine: this.getLineNumber(n.getStart(this.sourceFile)),
651
687
  endLine: this.getLineNumber(n.getEnd()),
652
688
  blockType: 'Loop'
653
689
  })
654
- } else if (ts.isVariableStatement(n) || ts.isExpressionStatement(n)) {
655
- // Ignore single lines for brevity unless part of larger logical units
656
690
  }
657
691
  ts.forEachChild(n, walkBlocks)
658
692
  }
@@ -705,15 +739,45 @@ export class TypeScriptExtractor {
705
739
  return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
706
740
  }
707
741
 
708
- /** 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
+ */
709
750
  protected walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
710
751
  ts.forEachChild(node, (child) => {
711
752
  callback(child)
753
+ this.walkNode(child, callback)
712
754
  })
713
755
  }
714
756
  }
715
757
 
716
- //
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
+ }
717
781
 
718
782
  /**
719
783
  * Derive a human-readable purpose sentence from a camelCase/PascalCase identifier.