@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 +1 -1
- package/src/contract/lock-compiler.ts +8 -7
- package/src/contract/schema.ts +6 -1
- package/src/graph/dead-code-detector.ts +27 -5
- package/src/graph/graph-builder.ts +20 -3
- package/src/graph/impact-analyzer.ts +12 -6
- package/src/graph/types.ts +1 -0
- package/src/parser/boundary-checker.ts +3 -1
- package/src/parser/go/go-extractor.ts +10 -1
- package/src/parser/tree-sitter/parser.ts +8 -5
- package/src/parser/typescript/ts-extractor.ts +103 -39
package/package.json
CHANGED
|
@@ -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
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
.
|
|
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
|
-
...(
|
|
340
|
+
...(imports.length > 0 ? { imports } : {}),
|
|
340
341
|
}
|
|
341
342
|
}
|
|
342
343
|
|
package/src/contract/schema.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
const { id: current, depth, confidence: pathConf } = queue
|
|
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
|
-
|
|
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' :
|
package/src/graph/types.ts
CHANGED
|
@@ -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
|
|
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 === '`'
|
|
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:
|
|
220
|
-
let fnId = `fn:${filePath}:${fnName}:${
|
|
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 = `
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
407
|
-
name:
|
|
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:
|
|
436
|
-
name:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
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.
|