@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.
- package/package.json +1 -1
- package/src/contract/contract-reader.ts +9 -9
- package/src/contract/lock-reader.ts +9 -6
- package/src/graph/dead-code-detector.ts +111 -52
- package/src/graph/graph-builder.ts +199 -61
- package/src/graph/impact-analyzer.ts +48 -16
- package/src/index.ts +1 -1
- package/src/parser/javascript/js-extractor.ts +22 -6
- package/src/parser/javascript/js-parser.ts +24 -17
- package/src/parser/javascript/js-resolver.ts +63 -22
- package/src/parser/parser-constants.ts +82 -0
- package/src/parser/tree-sitter/parser.ts +353 -69
- package/src/parser/types.ts +2 -0
- package/src/parser/typescript/ts-extractor.ts +17 -6
- package/src/parser/typescript/ts-parser.ts +22 -18
- package/src/parser/typescript/ts-resolver.ts +78 -45
- package/src/utils/fs.ts +64 -0
- package/src/utils/json.ts +27 -0
- package/tests/js-parser.test.ts +23 -3
- package/tests/tree-sitter-parser.test.ts +11 -3
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
236
|
+
id: fnId,
|
|
86
237
|
name: fnName,
|
|
87
238
|
file: filePath,
|
|
88
|
-
startLine
|
|
89
|
-
endLine
|
|
90
|
-
params
|
|
91
|
-
returnType
|
|
92
|
-
isExported:
|
|
93
|
-
isAsync
|
|
94
|
-
calls: [], //
|
|
95
|
-
hash: hashContent(
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
117
|
-
endLine
|
|
279
|
+
startLine,
|
|
280
|
+
endLine,
|
|
118
281
|
methods: [],
|
|
119
|
-
isExported:
|
|
282
|
+
isExported: isExportedByLanguage(ext, clsName, nodeText),
|
|
120
283
|
})
|
|
121
284
|
}
|
|
122
285
|
}
|
|
123
286
|
}
|
|
124
287
|
}
|
|
125
288
|
|
|
126
|
-
//
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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.
|
|
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[],
|
|
159
|
-
//
|
|
160
|
-
//
|
|
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:
|
|
180
|
-
functions: [],
|
|
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
|
+
}
|
package/src/parser/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
557
|
+
clean = comment.replace(/^\/\/+\s?/, '').trim()
|
|
547
558
|
}
|
|
548
559
|
|
|
549
560
|
// Skip divider lines (lines with 3+ repeated special characters)
|