@getmikk/core 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,18 +2,143 @@ import * as path from 'node:path'
2
2
  import { createRequire } from 'node:module'
3
3
  import { hashContent } from '../../hash/file-hasher.js'
4
4
  import { BaseParser } from '../base-parser.js'
5
- import type { ParsedFile, ParsedFunction, ParsedClass, ParsedParam, ParsedImport, ParsedExport, ParsedRoute } from '../types.js'
5
+ import type { ParsedFile, ParsedFunction, ParsedClass, ParsedParam, ParsedImport } from '../types.js'
6
6
  import * as Queries from './queries.js'
7
7
 
8
8
  // Safely require web-tree-sitter via CJS
9
9
  const getRequire = () => {
10
- if (typeof require !== 'undefined') return require;
11
- return createRequire(import.meta.url);
12
- };
13
- const _require = getRequire();
10
+ if (typeof require !== 'undefined') return require
11
+ return createRequire(import.meta.url)
12
+ }
13
+ const _require = getRequire()
14
14
  const ParserModule = _require('web-tree-sitter')
15
15
  const Parser = ParserModule.Parser || ParserModule
16
16
 
17
+ // ---------------------------------------------------------------------------
18
+ // Language-specific export visibility rules
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Determine whether a function node is exported based on language conventions.
23
+ * Python: public if name does not start with underscore.
24
+ * Java/C#/Rust: requires an explicit visibility keyword in the node text.
25
+ * Go: exported if name starts with an uppercase letter.
26
+ * All others (C, C++, PHP, Ruby): default to false (no reliable static rule).
27
+ */
28
+ function isExportedByLanguage(ext: string, name: string, nodeText: string): boolean {
29
+ switch (ext) {
30
+ case '.py':
31
+ return !name.startsWith('_')
32
+ case '.java':
33
+ case '.cs':
34
+ return /\bpublic\b/.test(nodeText)
35
+ case '.go':
36
+ return name.length > 0 && name[0] === name[0].toUpperCase() && name[0] !== name[0].toLowerCase()
37
+ case '.rs':
38
+ return /\bpub\b/.test(nodeText)
39
+ default:
40
+ return false
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Parameter extraction from tree-sitter nodes
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Best-effort parameter extraction from a function definition node.
50
+ * Walks child nodes looking for parameter/formal_parameter identifiers.
51
+ * Returns an empty array on failure — never throws.
52
+ */
53
+ function extractParamsFromNode(defNode: any): ParsedParam[] {
54
+ const params: ParsedParam[] = []
55
+ if (!defNode || !defNode.children) return params
56
+
57
+ // Walk all descendants looking for parameter-like nodes
58
+ const walk = (node: any) => {
59
+ if (!node) return
60
+ const t = node.type ?? ''
61
+ // Common parameter node type names across tree-sitter grammars
62
+ if (
63
+ t === 'parameter' || t === 'formal_parameter' || t === 'simple_parameter' ||
64
+ t === 'variadic_parameter' || t === 'typed_parameter' || t === 'typed_default_parameter' ||
65
+ t === 'keyword_argument' || t === 'field_declaration'
66
+ ) {
67
+ // Try to find the identifier within this param node
68
+ const identNode = findFirstChild(node, n => n.type === 'identifier' || n.type === 'name')
69
+ const typeNode = findFirstChild(node, n =>
70
+ n.type === 'type' || n.type === 'type_annotation' ||
71
+ n.type === 'type_identifier' || n.type === 'predefined_type'
72
+ )
73
+ const name = identNode?.text ?? node.text ?? ''
74
+ const type = typeNode?.text ?? 'any'
75
+ if (name && name !== '' && !params.some(p => p.name === name)) {
76
+ params.push({ name, type, optional: false })
77
+ }
78
+ return // Don't recurse into parameter children
79
+ }
80
+ if (node.children) {
81
+ for (const child of node.children) walk(child)
82
+ }
83
+ }
84
+
85
+ walk(defNode)
86
+ return params
87
+ }
88
+
89
+ function findFirstChild(node: any, predicate: (n: any) => boolean): any {
90
+ if (!node?.children) return null
91
+ for (const child of node.children) {
92
+ if (predicate(child)) return child
93
+ }
94
+ return null
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Scope-aware call resolver
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Given the ordered list of functions (with startLine/endLine already set)
103
+ * and a map of callName → line, assign each call to the innermost function
104
+ * whose line range contains that call's line.
105
+ *
106
+ * Returns an array of call names that were NOT assigned to any function scope
107
+ * (these are module-scope calls).
108
+ */
109
+ function assignCallsToFunctions(
110
+ functions: ParsedFunction[],
111
+ callEntries: Array<{ name: string; line: number }>
112
+ ): string[] {
113
+ const unassigned: string[] = []
114
+ for (const { name, line } of callEntries) {
115
+ // Find the innermost (smallest range) function that contains this line
116
+ let best: ParsedFunction | null = null
117
+ let bestRange = Infinity
118
+ for (const fn of functions) {
119
+ if (line >= fn.startLine && line <= fn.endLine) {
120
+ const range = fn.endLine - fn.startLine
121
+ if (range < bestRange) {
122
+ best = fn
123
+ bestRange = range
124
+ }
125
+ }
126
+ }
127
+ if (best) {
128
+ if (!best.calls.includes(name)) {
129
+ best.calls.push(name)
130
+ }
131
+ } else {
132
+ unassigned.push(name)
133
+ }
134
+ }
135
+ return unassigned
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Main parser class
140
+ // ---------------------------------------------------------------------------
141
+
17
142
  export class TreeSitterParser extends BaseParser {
18
143
  private parser: any = null
19
144
  private languages = new Map<string, any>()
@@ -35,7 +160,6 @@ export class TreeSitterParser extends BaseParser {
35
160
  const config = await this.getLanguageConfig(ext)
36
161
 
37
162
  if (!config || !config.lang) {
38
- // Fallback to empty if language not supported or grammar failed to load
39
163
  return this.buildEmptyFile(filePath, content, ext)
40
164
  }
41
165
 
@@ -47,7 +171,10 @@ export class TreeSitterParser extends BaseParser {
47
171
  const functions: ParsedFunction[] = []
48
172
  const classesMap = new Map<string, ParsedClass>()
49
173
  const imports: ParsedImport[] = []
50
- const calls = new Set<string>()
174
+ // callEntries stores name + line so we can scope them to the right function
175
+ const callEntries: Array<{ name: string; line: number }> = []
176
+ // Track processed function IDs to avoid collisions from overloads
177
+ const seenFnIds = new Set<string>()
51
178
 
52
179
  for (const match of matches) {
53
180
  const captures: Record<string, any> = {}
@@ -55,13 +182,17 @@ export class TreeSitterParser extends BaseParser {
55
182
  captures[c.name] = c.node
56
183
  }
57
184
 
58
- // Calls
185
+ // --- Calls: record name and line position ---
59
186
  if (captures['call.name']) {
60
- calls.add(captures['call.name'].text)
187
+ const callNode = captures['call.name']
188
+ callEntries.push({
189
+ name: callNode.text,
190
+ line: (callNode.startPosition?.row ?? 0) + 1,
191
+ })
61
192
  continue
62
193
  }
63
194
 
64
- // Imports
195
+ // --- Imports ---
65
196
  if (captures['import.source']) {
66
197
  const src = captures['import.source'].text.replace(/['"]/g, '')
67
198
  imports.push({
@@ -69,31 +200,51 @@ export class TreeSitterParser extends BaseParser {
69
200
  resolvedPath: '',
70
201
  names: [],
71
202
  isDefault: false,
72
- isDynamic: false
203
+ isDynamic: false,
73
204
  })
74
205
  continue
75
206
  }
76
207
 
77
- // Functions / Methods
208
+ // --- Functions / Methods ---
78
209
  if (captures['definition.function'] || captures['definition.method']) {
79
210
  const nameNode = captures['name']
80
211
  const defNode = captures['definition.function'] || captures['definition.method']
81
-
212
+
82
213
  if (nameNode && defNode) {
83
214
  const fnName = nameNode.text
215
+ const startLine = defNode.startPosition.row + 1
216
+ const endLine = defNode.endPosition.row + 1
217
+ const nodeText = defNode.text ?? ''
218
+
219
+ // Unique ID: include start line to handle overloads and same-name scoped functions
220
+ let fnId = `fn:${filePath}:${fnName}:${startLine}`
221
+ if (seenFnIds.has(fnId)) {
222
+ // Extremely rare duplicate — skip rather than corrupt
223
+ continue
224
+ }
225
+ seenFnIds.add(fnId)
226
+
227
+ const exported = isExportedByLanguage(ext, fnName, nodeText)
228
+ const isAsync = /\basync\b/.test(nodeText)
229
+
230
+ // Detect return type — language-specific heuristics
231
+ const returnType = extractReturnType(ext, defNode)
232
+
233
+ const params = extractParamsFromNode(defNode)
234
+
84
235
  functions.push({
85
- id: `fn:${filePath}:${fnName}`,
236
+ id: fnId,
86
237
  name: fnName,
87
238
  file: filePath,
88
- startLine: defNode.startPosition.row + 1,
89
- endLine: defNode.endPosition.row + 1,
90
- params: [],
91
- returnType: 'any',
92
- isExported: true, // simplified for universal parser
93
- isAsync: false,
94
- calls: [], // We aggregate at file level currently
95
- hash: hashContent(defNode.text),
96
- purpose: '',
239
+ startLine,
240
+ endLine,
241
+ params,
242
+ returnType,
243
+ isExported: exported,
244
+ isAsync,
245
+ calls: [], // populated after all functions are collected
246
+ hash: hashContent(nodeText),
247
+ purpose: extractDocComment(content, startLine),
97
248
  edgeCasesHandled: [],
98
249
  errorHandling: [],
99
250
  detailedLines: [],
@@ -101,46 +252,71 @@ export class TreeSitterParser extends BaseParser {
101
252
  }
102
253
  }
103
254
 
104
- // Classes / Structs / Interfaces
105
- if (captures['definition.class'] || captures['definition.struct'] || captures['definition.interface']) {
255
+ // --- Classes / Structs / Interfaces ---
256
+ if (
257
+ captures['definition.class'] ||
258
+ captures['definition.struct'] ||
259
+ captures['definition.interface']
260
+ ) {
106
261
  const nameNode = captures['name']
107
- const defNode = captures['definition.class'] || captures['definition.struct'] || captures['definition.interface']
108
-
262
+ const defNode =
263
+ captures['definition.class'] ||
264
+ captures['definition.struct'] ||
265
+ captures['definition.interface']
266
+
109
267
  if (nameNode && defNode) {
110
268
  const clsName = nameNode.text
111
- if (!classesMap.has(clsName)) {
112
- classesMap.set(clsName, {
113
- id: `cls:${filePath}:${clsName}`,
269
+ const startLine = defNode.startPosition.row + 1
270
+ const endLine = defNode.endPosition.row + 1
271
+ const nodeText = defNode.text ?? ''
272
+ const clsId = `cls:${filePath}:${clsName}:${startLine}`
273
+
274
+ if (!classesMap.has(clsId)) {
275
+ classesMap.set(clsId, {
276
+ id: clsId,
114
277
  name: clsName,
115
278
  file: filePath,
116
- startLine: defNode.startPosition.row + 1,
117
- endLine: defNode.endPosition.row + 1,
279
+ startLine,
280
+ endLine,
118
281
  methods: [],
119
- isExported: true,
282
+ isExported: isExportedByLanguage(ext, clsName, nodeText),
120
283
  })
121
284
  }
122
285
  }
123
286
  }
124
287
  }
125
288
 
126
- // Attach global calls to the first function as a heuristic, or store in a dummy
127
- if (functions.length > 0) {
128
- functions[0].calls = Array.from(calls)
129
- }
289
+ // Assign calls to their enclosing function scopes.
290
+ const unassignedCalls = assignCallsToFunctions(functions, callEntries)
130
291
 
131
- let finalLang: ParsedFile['language'] = 'go'
132
- switch (ext) {
133
- case '.py': finalLang = 'python'; break
134
- case '.java': finalLang = 'java'; break
135
- case '.c': case '.h': finalLang = 'c'; break
136
- case '.cpp': case '.cc': case '.hpp': finalLang = 'cpp'; break
137
- case '.cs': finalLang = 'csharp'; break
138
- case '.go': finalLang = 'go'; break
139
- case '.rs': finalLang = 'rust'; break
140
- case '.php': finalLang = 'php'; break
141
- case '.rb': finalLang = 'ruby'; break
292
+ // Only add a synthetic module-level function if there are actually calls made outside any function.
293
+ if (unassignedCalls.length > 0) {
294
+ const lineCount = content.split('\n').length
295
+ functions.push({
296
+ id: `fn:${filePath}:<module>:1`,
297
+ name: '<module>',
298
+ file: filePath,
299
+ startLine: 1,
300
+ endLine: lineCount || 1,
301
+ params: [],
302
+ returnType: 'void',
303
+ isExported: true,
304
+ isAsync: false,
305
+ calls: Array.from(new Set(unassignedCalls)),
306
+ hash: '',
307
+ purpose: 'Module-level initialization code',
308
+ edgeCasesHandled: [],
309
+ errorHandling: [],
310
+ detailedLines: [],
311
+ })
142
312
  }
143
313
 
314
+ const finalLang = extensionToLanguage(ext)
315
+
316
+ // Link methods: functions whose names contain '.' belong to a class
317
+ // (Go receiver methods, Java/C# member methods detected via method capture)
318
+ linkMethodsToClasses(functions, classesMap)
319
+
144
320
  return {
145
321
  path: filePath,
146
322
  language: finalLang,
@@ -148,45 +324,42 @@ export class TreeSitterParser extends BaseParser {
148
324
  classes: Array.from(classesMap.values()),
149
325
  generics: [],
150
326
  imports,
151
- exports: functions.map(f => ({ name: f.name, type: 'function', file: filePath })),
327
+ exports: functions.filter(f => f.isExported).map(f => ({
328
+ name: f.name,
329
+ type: 'function' as const,
330
+ file: filePath,
331
+ })),
152
332
  routes: [],
153
333
  hash: hashContent(content),
154
- parsedAt: Date.now()
334
+ parsedAt: Date.now(),
155
335
  }
156
336
  }
157
337
 
158
- resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
159
- // Universal resolver: just link absolute paths if they exist locally
160
- // Basic heuristic for all 11 languages
338
+ resolveImports(files: ParsedFile[], _projectRoot: string): ParsedFile[] {
339
+ // Tree-sitter resolver: no cross-file resolution implemented.
340
+ // Imports are left with resolvedPath = '' which signals unresolved to the graph builder.
341
+ // A future pass can resolve Go/Python/Java imports using language-specific rules.
161
342
  return files
162
343
  }
163
344
 
164
345
  private buildEmptyFile(filePath: string, content: string, ext: string): ParsedFile {
165
- let finalLang: ParsedFile['language'] = 'unknown'
166
- switch (ext) {
167
- case '.py': finalLang = 'python'; break
168
- case '.java': finalLang = 'java'; break
169
- case '.c': case '.h': finalLang = 'c'; break
170
- case '.cpp': case '.cc': case '.hpp': finalLang = 'cpp'; break
171
- case '.cs': finalLang = 'csharp'; break
172
- case '.go': finalLang = 'go'; break
173
- case '.rs': finalLang = 'rust'; break
174
- case '.php': finalLang = 'php'; break
175
- case '.rb': finalLang = 'ruby'; break
176
- }
177
346
  return {
178
347
  path: filePath,
179
- language: finalLang,
180
- functions: [], classes: [], generics: [], imports: [], exports: [], routes: [],
348
+ language: extensionToLanguage(ext),
349
+ functions: [],
350
+ classes: [],
351
+ generics: [],
352
+ imports: [],
353
+ exports: [],
354
+ routes: [],
181
355
  hash: hashContent(content),
182
- parsedAt: Date.now()
356
+ parsedAt: Date.now(),
183
357
  }
184
358
  }
185
359
 
186
360
  private async loadLang(name: string): Promise<any> {
187
361
  if (this.languages.has(name)) return this.languages.get(name)
188
362
  try {
189
- // Get module root path to locate wasms
190
363
  const tcPath = _require.resolve('tree-sitter-wasms/package.json')
191
364
  const wasmPath = path.join(path.dirname(tcPath), 'out', `tree-sitter-${name}.wasm`)
192
365
  const lang = await Parser.Language.load(wasmPath)
@@ -226,3 +399,114 @@ export class TreeSitterParser extends BaseParser {
226
399
  }
227
400
  }
228
401
  }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Helpers
405
+ // ---------------------------------------------------------------------------
406
+
407
+ function extensionToLanguage(ext: string): ParsedFile['language'] {
408
+ switch (ext) {
409
+ case '.py': return 'python'
410
+ case '.java': return 'java'
411
+ case '.c': case '.h': return 'c'
412
+ case '.cpp': case '.cc': case '.hpp': return 'cpp'
413
+ case '.cs': return 'csharp'
414
+ case '.go': return 'go'
415
+ case '.rs': return 'rust'
416
+ case '.php': return 'php'
417
+ case '.rb': return 'ruby'
418
+ default: return 'unknown'
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Extract a simple return type hint from the function node text.
424
+ * Falls back to 'unknown' rather than 'any' to distinguish "not parsed"
425
+ * from "genuinely untyped".
426
+ */
427
+ function extractReturnType(ext: string, defNode: any): string {
428
+ const text: string = defNode?.text ?? ''
429
+ // TypeScript/Go/Rust: look for "-> Type" or ": Type" after parameters
430
+ const arrowMatch = text.match(/\)\s*->\s*([^\s{]+)/)
431
+ if (arrowMatch) return arrowMatch[1].trim()
432
+ // Java/C# style: "public int foo(" — type precedes the name
433
+ // This is too fragile to do reliably here; return 'unknown'
434
+ if (ext === '.go') {
435
+ // Go: "func foo() (int, error)" or "func foo() error"
436
+ const goReturnTuple = text.match(/\)\s+(\([^)]+\))/)
437
+ if (goReturnTuple) return goReturnTuple[1].trim()
438
+ const goReturn = text.match(/\)\s+([^\s{(]+)/)
439
+ if (goReturn) return goReturn[1].trim()
440
+ }
441
+ return 'unknown'
442
+ }
443
+
444
+ /**
445
+ * Extract a single-line doc comment immediately preceding the given line.
446
+ * Scans backwards from startLine looking for `#`, `//`, `/**`, or `"""` comments.
447
+ */
448
+ function extractDocComment(content: string, startLine: number): string {
449
+ const lines = content.split('\n')
450
+ const targetIdx = startLine - 2 // 0-indexed line before the function
451
+ if (targetIdx < 0) return ''
452
+
453
+ const prev = lines[targetIdx]?.trim() ?? ''
454
+ // Single-line comment styles
455
+ for (const prefix of ['# ', '// ', '/// ']) {
456
+ if (prev.startsWith(prefix)) return prev.slice(prefix.length).trim()
457
+ }
458
+ // JSDoc / block comment end
459
+ if (prev === '*/') {
460
+ // Walk back to find the first meaningful JSDoc line
461
+ for (let i = targetIdx - 1; i >= 0; i--) {
462
+ const line = lines[i].trim()
463
+ if (line.startsWith('/*') || line.startsWith('/**')) break
464
+ const cleaned = line.replace(/^\*+\s?/, '')
465
+ if (cleaned && !/^[\-_=*]{3,}$/.test(cleaned)) return cleaned
466
+ }
467
+ }
468
+ return ''
469
+ }
470
+
471
+ /**
472
+ * Move functions that are class methods (identified by having a receiver or
473
+ * by being within the line range of a class) into the class's methods array.
474
+ * This is a best-effort heuristic; direct tree-sitter capture of method
475
+ * declarations already places them correctly in most languages.
476
+ */
477
+ function linkMethodsToClasses(
478
+ functions: ParsedFunction[],
479
+ classesMap: Map<string, ParsedClass>
480
+ ): void {
481
+ const classes = Array.from(classesMap.values())
482
+ if (classes.length === 0) return
483
+
484
+ for (const fn of functions) {
485
+ // Already categorised if name contains "." (e.g. "MyClass.method")
486
+ // and never link the synthetic <module> function to a class.
487
+ if (fn.name === '<module>' || fn.name.includes('.')) continue
488
+
489
+ // Skip functions nested inside other functions (local helpers)
490
+ const isNestedInFunction = functions.some(f =>
491
+ f.id !== fn.id &&
492
+ fn.startLine >= f.startLine && fn.endLine <= f.endLine
493
+ )
494
+ if (isNestedInFunction) continue
495
+
496
+ // Find the innermost (smallest range) class that contains this function
497
+ let bestCls: ParsedClass | null = null
498
+ let bestRange = Infinity
499
+ for (const cls of classes) {
500
+ if (fn.startLine > cls.startLine && fn.endLine <= cls.endLine) {
501
+ const range = cls.endLine - cls.startLine
502
+ if (range < bestRange) {
503
+ bestCls = cls
504
+ bestRange = range
505
+ }
506
+ }
507
+ }
508
+ if (bestCls && !bestCls.methods.some(m => m.id === fn.id)) {
509
+ bestCls.methods.push(fn)
510
+ }
511
+ }
512
+ }
@@ -15,6 +15,7 @@ export interface ParsedFunction {
15
15
  id: string // "fn:auth/verify.ts:verifyToken"
16
16
  name: string // "verifyToken"
17
17
  file: string // "src/auth/verify.ts"
18
+ moduleId?: string
18
19
  startLine: number // 14
19
20
  endLine: number // 28
20
21
  params: ParsedParam[] // [{name: "token", type: "string"}]
@@ -52,6 +53,7 @@ export interface ParsedClass {
52
53
  id: string
53
54
  name: string
54
55
  file: string
56
+ moduleId?: string
55
57
  startLine: number
56
58
  endLine: number
57
59
  methods: ParsedFunction[]
@@ -324,7 +324,9 @@ export class TypeScriptExtractor {
324
324
  const bodyText = node.getText(this.sourceFile)
325
325
 
326
326
  return {
327
- id: `fn:${this.filePath}:${name}`,
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}`,
328
330
  name,
329
331
  file: this.filePath,
330
332
  startLine,
@@ -361,7 +363,9 @@ export class TypeScriptExtractor {
361
363
  const bodyText = stmt.getText(this.sourceFile)
362
364
 
363
365
  return {
364
- id: `fn:${this.filePath}:${name}`,
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}`,
365
369
  name,
366
370
  file: this.filePath,
367
371
  startLine,
@@ -399,7 +403,7 @@ export class TypeScriptExtractor {
399
403
  const bodyText = member.getText(this.sourceFile)
400
404
 
401
405
  methods.push({
402
- id: `fn:${this.filePath}:${name}.constructor`,
406
+ id: `fn:${this.filePath}:${name}.constructor:${mStartLine}`,
403
407
  name: `${name}.constructor`,
404
408
  file: this.filePath,
405
409
  startLine: mStartLine,
@@ -428,7 +432,7 @@ export class TypeScriptExtractor {
428
432
  const bodyText = member.getText(this.sourceFile)
429
433
 
430
434
  methods.push({
431
- id: `fn:${this.filePath}:${name}.${methodName}`,
435
+ id: `fn:${this.filePath}:${name}.${methodName}:${mStartLine}`,
432
436
  name: `${name}.${methodName}`,
433
437
  file: this.filePath,
434
438
  startLine: mStartLine,
@@ -541,9 +545,16 @@ export class TypeScriptExtractor {
541
545
  const comment = fullText.slice(range.pos, range.end)
542
546
  let clean = ''
543
547
  if (comment.startsWith('/**') || comment.startsWith('/*')) {
544
- clean = comment.replace(/[\/\*]/g, '').trim()
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
+ clean = comment
552
+ .replace(/^\/\*+/, '') // remove leading /* or /**
553
+ .replace(/\*+\/$/, '') // remove trailing */
554
+ .replace(/^\s*\*+\s?/gm, '') // remove leading * on each line
555
+ .trim()
545
556
  } else if (comment.startsWith('//')) {
546
- clean = comment.replace(/\/\//g, '').trim()
557
+ clean = comment.replace(/^\/\/+\s?/, '').trim()
547
558
  }
548
559
 
549
560
  // Skip divider lines (lines with 3+ repeated special characters)