@getmikk/core 1.8.3 → 1.9.0
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 +3 -1
- package/src/constants.ts +285 -0
- package/src/contract/contract-generator.ts +7 -0
- package/src/contract/index.ts +2 -3
- package/src/contract/lock-compiler.ts +66 -35
- package/src/contract/lock-reader.ts +24 -4
- package/src/contract/schema.ts +21 -0
- package/src/error-handler.ts +430 -0
- package/src/graph/cluster-detector.ts +45 -20
- package/src/graph/confidence-engine.ts +60 -0
- package/src/graph/graph-builder.ts +298 -255
- package/src/graph/impact-analyzer.ts +130 -119
- package/src/graph/index.ts +4 -0
- package/src/graph/memory-manager.ts +345 -0
- package/src/graph/query-engine.ts +79 -0
- package/src/graph/risk-engine.ts +86 -0
- package/src/graph/types.ts +89 -65
- package/src/parser/change-detector.ts +99 -0
- package/src/parser/go/go-extractor.ts +18 -8
- package/src/parser/go/go-parser.ts +2 -0
- package/src/parser/index.ts +88 -38
- package/src/parser/javascript/js-extractor.ts +1 -1
- package/src/parser/javascript/js-parser.ts +2 -0
- package/src/parser/oxc-parser.ts +675 -0
- package/src/parser/oxc-resolver.ts +83 -0
- package/src/parser/tree-sitter/parser.ts +19 -10
- package/src/parser/types.ts +100 -73
- package/src/parser/typescript/ts-extractor.ts +229 -589
- package/src/parser/typescript/ts-parser.ts +16 -171
- package/src/parser/typescript/ts-resolver.ts +11 -1
- package/src/search/bm25.ts +5 -2
- package/src/utils/minimatch.ts +1 -1
- package/tests/contract.test.ts +2 -2
- package/tests/dead-code.test.ts +7 -7
- package/tests/esm-resolver.test.ts +75 -0
- package/tests/graph.test.ts +20 -20
- package/tests/helpers.ts +11 -6
- package/tests/impact-classified.test.ts +37 -41
- package/tests/parser.test.ts +7 -5
- package/tests/ts-parser.test.ts +27 -52
- package/test-output.txt +0 -373
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ParsedFunction,
|
|
4
|
+
ParsedClass,
|
|
5
|
+
ParsedImport,
|
|
6
|
+
ParsedExport,
|
|
7
|
+
ParsedParam,
|
|
8
|
+
ParsedGeneric,
|
|
9
|
+
ParsedRoute,
|
|
10
|
+
ParsedVariable,
|
|
11
|
+
CallExpression
|
|
12
|
+
} from '../types.js'
|
|
3
13
|
import { hashContent } from '../../hash/file-hasher.js'
|
|
4
14
|
|
|
5
|
-
/**
|
|
6
|
-
* TypeScript AST extractor walks the TypeScript AST using the TS Compiler API
|
|
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
|
|
15
|
-
*/
|
|
16
15
|
export class TypeScriptExtractor {
|
|
17
16
|
protected readonly sourceFile: ts.SourceFile
|
|
18
|
-
/** Per-file collision counter: name -> count of times seen so far */
|
|
19
17
|
private nameCounter = new Map<string, number>()
|
|
20
18
|
|
|
21
19
|
constructor(
|
|
@@ -26,12 +24,11 @@ export class TypeScriptExtractor {
|
|
|
26
24
|
filePath,
|
|
27
25
|
content,
|
|
28
26
|
ts.ScriptTarget.Latest,
|
|
29
|
-
true,
|
|
27
|
+
true,
|
|
30
28
|
this.inferScriptKind(filePath)
|
|
31
29
|
)
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
/** Infer TypeScript ScriptKind from file extension (supports JS/JSX/TS/TSX) */
|
|
35
32
|
private inferScriptKind(filePath: string): ts.ScriptKind {
|
|
36
33
|
if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX
|
|
37
34
|
if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX
|
|
@@ -39,40 +36,28 @@ export class TypeScriptExtractor {
|
|
|
39
36
|
return ts.ScriptKind.TS
|
|
40
37
|
}
|
|
41
38
|
|
|
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 {
|
|
39
|
+
private allocateId(prefix: string, name: string): string {
|
|
49
40
|
const count = (this.nameCounter.get(name) ?? 0) + 1
|
|
50
41
|
this.nameCounter.set(name, count)
|
|
51
|
-
|
|
42
|
+
const suffix = count === 1 ? '' : `#${count}`
|
|
43
|
+
const normalizedPath = this.filePath.replace(/\\/g, '/')
|
|
44
|
+
return `${prefix}:${normalizedPath}:${name}${suffix}`.toLowerCase()
|
|
52
45
|
}
|
|
53
46
|
|
|
54
|
-
/** Reset the collision counter (call before each full extraction pass if reused) */
|
|
55
47
|
resetCounters(): void {
|
|
56
48
|
this.nameCounter.clear()
|
|
57
49
|
}
|
|
58
50
|
|
|
59
|
-
/** Extract all top-level and variable-assigned functions */
|
|
60
51
|
extractFunctions(): ParsedFunction[] {
|
|
61
52
|
const functions: ParsedFunction[] = []
|
|
62
53
|
this.walkNode(this.sourceFile, (node) => {
|
|
63
|
-
// function declarations: function foo() {}
|
|
64
54
|
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
65
55
|
functions.push(this.parseFunctionDeclaration(node))
|
|
66
56
|
}
|
|
67
|
-
// variable declarations with arrow functions or function expressions:
|
|
68
|
-
// const foo = () => {} or const foo = function() {}
|
|
69
57
|
if (ts.isVariableStatement(node)) {
|
|
70
58
|
for (const decl of node.declarationList.declarations) {
|
|
71
59
|
if (decl.initializer && ts.isIdentifier(decl.name)) {
|
|
72
|
-
if (
|
|
73
|
-
ts.isArrowFunction(decl.initializer) ||
|
|
74
|
-
ts.isFunctionExpression(decl.initializer)
|
|
75
|
-
) {
|
|
60
|
+
if (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) {
|
|
76
61
|
functions.push(this.parseVariableFunction(node, decl, decl.initializer))
|
|
77
62
|
}
|
|
78
63
|
}
|
|
@@ -82,7 +67,6 @@ export class TypeScriptExtractor {
|
|
|
82
67
|
return functions
|
|
83
68
|
}
|
|
84
69
|
|
|
85
|
-
/** Extract all class declarations */
|
|
86
70
|
extractClasses(): ParsedClass[] {
|
|
87
71
|
const classes: ParsedClass[] = []
|
|
88
72
|
this.walkNode(this.sourceFile, (node) => {
|
|
@@ -93,715 +77,371 @@ export class TypeScriptExtractor {
|
|
|
93
77
|
return classes
|
|
94
78
|
}
|
|
95
79
|
|
|
96
|
-
|
|
80
|
+
extractVariables(): ParsedVariable[] {
|
|
81
|
+
const variables: ParsedVariable[] = []
|
|
82
|
+
this.walkNode(this.sourceFile, (node) => {
|
|
83
|
+
if (ts.isVariableStatement(node)) {
|
|
84
|
+
for (const decl of node.declarationList.declarations) {
|
|
85
|
+
if (ts.isIdentifier(decl.name) && !this.isFunctionLike(decl.initializer)) {
|
|
86
|
+
variables.push(this.parseVariable(node, decl))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
return variables
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private isFunctionLike(node?: ts.Node): boolean {
|
|
95
|
+
return !!node && (ts.isArrowFunction(node) || ts.isFunctionExpression(node))
|
|
96
|
+
}
|
|
97
|
+
|
|
97
98
|
extractGenerics(): ParsedGeneric[] {
|
|
98
99
|
const generics: ParsedGeneric[] = []
|
|
99
100
|
this.walkNode(this.sourceFile, (node) => {
|
|
100
101
|
if (ts.isInterfaceDeclaration(node)) {
|
|
101
|
-
const tp = this.extractTypeParameters(node.typeParameters)
|
|
102
102
|
generics.push({
|
|
103
|
-
id:
|
|
103
|
+
id: this.allocateId('intf', node.name.text),
|
|
104
104
|
name: node.name.text,
|
|
105
105
|
type: 'interface',
|
|
106
106
|
file: this.filePath,
|
|
107
107
|
startLine: this.getLineNumber(node.getStart(this.sourceFile)),
|
|
108
108
|
endLine: this.getLineNumber(node.getEnd()),
|
|
109
109
|
isExported: this.hasExportModifier(node),
|
|
110
|
-
|
|
110
|
+
typeParameters: this.extractTypeParameters(node.typeParameters),
|
|
111
|
+
hash: hashContent(node.getText(this.sourceFile)),
|
|
111
112
|
purpose: this.extractPurpose(node),
|
|
112
113
|
})
|
|
113
114
|
} else if (ts.isTypeAliasDeclaration(node)) {
|
|
114
|
-
const tp = this.extractTypeParameters(node.typeParameters)
|
|
115
115
|
generics.push({
|
|
116
|
-
id:
|
|
116
|
+
id: this.allocateId('type', node.name.text),
|
|
117
117
|
name: node.name.text,
|
|
118
118
|
type: 'type',
|
|
119
119
|
file: this.filePath,
|
|
120
120
|
startLine: this.getLineNumber(node.getStart(this.sourceFile)),
|
|
121
121
|
endLine: this.getLineNumber(node.getEnd()),
|
|
122
122
|
isExported: this.hasExportModifier(node),
|
|
123
|
-
|
|
123
|
+
typeParameters: this.extractTypeParameters(node.typeParameters),
|
|
124
|
+
hash: hashContent(node.getText(this.sourceFile)),
|
|
124
125
|
purpose: this.extractPurpose(node),
|
|
125
126
|
})
|
|
126
|
-
} else if (ts.isVariableStatement(node) && !this.isVariableFunction(node)) {
|
|
127
|
-
// top-level constants (not functions)
|
|
128
|
-
for (const decl of node.declarationList.declarations) {
|
|
129
|
-
if (ts.isIdentifier(decl.name)) {
|
|
130
|
-
generics.push({
|
|
131
|
-
id: `const:${this.filePath}:${decl.name.text}`,
|
|
132
|
-
name: decl.name.text,
|
|
133
|
-
type: 'const',
|
|
134
|
-
file: this.filePath,
|
|
135
|
-
startLine: this.getLineNumber(node.getStart(this.sourceFile)),
|
|
136
|
-
endLine: this.getLineNumber(node.getEnd()),
|
|
137
|
-
isExported: this.hasExportModifier(node),
|
|
138
|
-
purpose: this.extractPurpose(node),
|
|
139
|
-
})
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
127
|
}
|
|
143
128
|
})
|
|
144
129
|
return generics
|
|
145
130
|
}
|
|
146
131
|
|
|
147
|
-
protected isVariableFunction(node: ts.VariableStatement): boolean {
|
|
148
|
-
for (const decl of node.declarationList.declarations) {
|
|
149
|
-
if (decl.initializer && (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))) {
|
|
150
|
-
return true
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return false
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Extract all import statements (static and dynamic) */
|
|
157
132
|
extractImports(): ParsedImport[] {
|
|
158
133
|
const imports: ParsedImport[] = []
|
|
159
134
|
this.walkNode(this.sourceFile, (node) => {
|
|
160
135
|
if (ts.isImportDeclaration(node)) {
|
|
136
|
+
if (node.importClause?.isTypeOnly) return;
|
|
161
137
|
const parsed = this.parseImport(node)
|
|
162
|
-
if (parsed)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// Also detect dynamic import() calls: await import('./path')
|
|
167
|
-
const walkDynamic = (n: ts.Node) => {
|
|
168
|
-
if (ts.isCallExpression(n) && n.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
169
|
-
const arg = n.arguments[0]
|
|
170
|
-
if (arg && ts.isStringLiteral(arg)) {
|
|
171
|
-
imports.push({
|
|
172
|
-
source: arg.text,
|
|
173
|
-
resolvedPath: '', // Filled in by resolver
|
|
174
|
-
names: [],
|
|
175
|
-
isDefault: false,
|
|
176
|
-
isDynamic: true,
|
|
177
|
-
})
|
|
138
|
+
if (parsed) {
|
|
139
|
+
// Filter out type-only named imports
|
|
140
|
+
parsed.names = parsed.names.filter(n => !n.startsWith('type '))
|
|
141
|
+
imports.push(parsed)
|
|
178
142
|
}
|
|
179
143
|
}
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
ts.forEachChild(this.sourceFile, walkDynamic)
|
|
183
|
-
|
|
144
|
+
})
|
|
184
145
|
return imports
|
|
185
146
|
}
|
|
186
147
|
|
|
187
|
-
/** Extract all exported symbols */
|
|
188
148
|
extractExports(): ParsedExport[] {
|
|
189
149
|
const exports: ParsedExport[] = []
|
|
190
150
|
this.walkNode(this.sourceFile, (node) => {
|
|
191
|
-
// export function foo() {}
|
|
192
151
|
if (ts.isFunctionDeclaration(node) && node.name && this.hasExportModifier(node)) {
|
|
193
|
-
exports.push({
|
|
194
|
-
name: node.name.text,
|
|
195
|
-
type: 'function',
|
|
196
|
-
file: this.filePath,
|
|
197
|
-
})
|
|
152
|
+
exports.push({ name: node.name.text, type: 'function', file: this.filePath })
|
|
198
153
|
}
|
|
199
|
-
// export class Foo {}
|
|
200
154
|
if (ts.isClassDeclaration(node) && node.name && this.hasExportModifier(node)) {
|
|
201
|
-
exports.push({
|
|
202
|
-
name: node.name.text,
|
|
203
|
-
type: 'class',
|
|
204
|
-
file: this.filePath,
|
|
205
|
-
})
|
|
155
|
+
exports.push({ name: node.name.text, type: 'class', file: this.filePath })
|
|
206
156
|
}
|
|
207
|
-
// export const foo = ...
|
|
208
157
|
if (ts.isVariableStatement(node) && this.hasExportModifier(node)) {
|
|
209
|
-
|
|
158
|
+
node.declarationList.declarations.forEach(decl => {
|
|
210
159
|
if (ts.isIdentifier(decl.name)) {
|
|
211
|
-
|
|
212
|
-
(ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))
|
|
213
|
-
? 'function' : 'const'
|
|
214
|
-
exports.push({
|
|
215
|
-
name: decl.name.text,
|
|
216
|
-
type: type as ParsedExport['type'],
|
|
217
|
-
file: this.filePath,
|
|
218
|
-
})
|
|
160
|
+
exports.push({ name: decl.name.text, type: 'const', file: this.filePath })
|
|
219
161
|
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
// export interface Foo {}
|
|
223
|
-
if (ts.isInterfaceDeclaration(node) && this.hasExportModifier(node)) {
|
|
224
|
-
exports.push({
|
|
225
|
-
name: node.name.text,
|
|
226
|
-
type: 'interface',
|
|
227
|
-
file: this.filePath,
|
|
228
162
|
})
|
|
229
163
|
}
|
|
230
|
-
// export type Foo = ...
|
|
231
|
-
if (ts.isTypeAliasDeclaration(node) && this.hasExportModifier(node)) {
|
|
232
|
-
exports.push({
|
|
233
|
-
name: node.name.text,
|
|
234
|
-
type: 'type',
|
|
235
|
-
file: this.filePath,
|
|
236
|
-
})
|
|
237
|
-
}
|
|
238
|
-
// export default ...
|
|
239
164
|
if (ts.isExportAssignment(node)) {
|
|
240
|
-
|
|
241
|
-
exports.push({
|
|
242
|
-
name,
|
|
243
|
-
type: 'default',
|
|
244
|
-
file: this.filePath,
|
|
245
|
-
})
|
|
246
|
-
}
|
|
247
|
-
// export { foo, bar } from './module'
|
|
248
|
-
if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
249
|
-
for (const element of node.exportClause.elements) {
|
|
250
|
-
exports.push({
|
|
251
|
-
name: element.name.text,
|
|
252
|
-
type: 'const',
|
|
253
|
-
file: this.filePath,
|
|
254
|
-
})
|
|
255
|
-
}
|
|
165
|
+
exports.push({ name: 'default', type: 'default', file: this.filePath })
|
|
256
166
|
}
|
|
257
167
|
})
|
|
258
168
|
return exports
|
|
259
169
|
}
|
|
260
170
|
|
|
261
|
-
/**
|
|
262
|
-
* Extract HTTP route registrations.
|
|
263
|
-
* Detects Express/Koa/Hono patterns like:
|
|
264
|
-
* router.get("/path", handler)
|
|
265
|
-
* app.post("/path", middleware, handler)
|
|
266
|
-
* app.use("/prefix", subrouter)
|
|
267
|
-
* router.use(middleware)
|
|
268
|
-
*/
|
|
269
171
|
extractRoutes(): ParsedRoute[] {
|
|
270
172
|
const routes: ParsedRoute[] = []
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Skip if receiver doesn't look like a router (e.g. prisma.file.delete)
|
|
292
|
-
if (!ROUTER_NAMES.has(receiverName)) {
|
|
293
|
-
ts.forEachChild(node, walk)
|
|
294
|
-
return
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const args = node.arguments
|
|
298
|
-
let routePath = ''
|
|
299
|
-
const middlewares: string[] = []
|
|
300
|
-
let handler = 'anonymous'
|
|
301
|
-
|
|
302
|
-
for (let i = 0; i < args.length; i++) {
|
|
303
|
-
const arg = args[i]
|
|
304
|
-
// First string literal is the route path
|
|
305
|
-
if (ts.isStringLiteral(arg) && !routePath) {
|
|
306
|
-
routePath = arg.text
|
|
307
|
-
} else if (ts.isIdentifier(arg)) {
|
|
308
|
-
// Last identifier is the handler; earlier ones are middleware
|
|
309
|
-
if (i === args.length - 1) {
|
|
310
|
-
handler = arg.text
|
|
311
|
-
} else {
|
|
312
|
-
middlewares.push(arg.text)
|
|
313
|
-
}
|
|
314
|
-
} else if (ts.isCallExpression(arg)) {
|
|
315
|
-
// e.g. upload.single("file") middleware call
|
|
316
|
-
middlewares.push(arg.expression.getText(this.sourceFile))
|
|
317
|
-
} else if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) {
|
|
318
|
-
handler = 'anonymous'
|
|
319
|
-
}
|
|
173
|
+
this.walkNode(this.sourceFile, (node) => {
|
|
174
|
+
if (ts.isCallExpression(node)) {
|
|
175
|
+
const text = node.expression.getText(this.sourceFile)
|
|
176
|
+
if (text.match(/^(router|app|express)\.(get|post|put|delete|patch)$/)) {
|
|
177
|
+
const method = text.split('.')[1].toUpperCase() as any
|
|
178
|
+
const pathArg = node.arguments[0]
|
|
179
|
+
if (pathArg && ts.isStringLiteral(pathArg)) {
|
|
180
|
+
const path = pathArg.text
|
|
181
|
+
const handler = node.arguments[node.arguments.length - 1]
|
|
182
|
+
const middlewares = node.arguments.slice(1, -1).map(a => a.getText(this.sourceFile))
|
|
183
|
+
routes.push({
|
|
184
|
+
method,
|
|
185
|
+
path,
|
|
186
|
+
handler: handler.getText(this.sourceFile),
|
|
187
|
+
middlewares,
|
|
188
|
+
file: this.filePath,
|
|
189
|
+
line: this.getLineNumber(node.getStart(this.sourceFile))
|
|
190
|
+
})
|
|
320
191
|
}
|
|
321
|
-
|
|
322
|
-
routes.push({
|
|
323
|
-
method: methodName.toUpperCase(),
|
|
324
|
-
path: routePath || '*',
|
|
325
|
-
handler,
|
|
326
|
-
middlewares,
|
|
327
|
-
file: this.filePath,
|
|
328
|
-
line: this.getLineNumber(node.getStart(this.sourceFile)),
|
|
329
|
-
})
|
|
330
192
|
}
|
|
331
193
|
}
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
ts.forEachChild(this.sourceFile, walk)
|
|
194
|
+
})
|
|
335
195
|
return routes
|
|
336
196
|
}
|
|
337
197
|
|
|
338
|
-
|
|
198
|
+
extractModuleCalls(): CallExpression[] {
|
|
199
|
+
// Calls occurring at the top level of the file
|
|
200
|
+
const calls: CallExpression[] = []
|
|
201
|
+
this.sourceFile.statements.forEach(stmt => {
|
|
202
|
+
if (!ts.isFunctionDeclaration(stmt) && !ts.isClassDeclaration(stmt)) {
|
|
203
|
+
calls.push(...this.extractCallsFromNode(stmt))
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
return calls
|
|
207
|
+
}
|
|
339
208
|
|
|
340
|
-
|
|
209
|
+
// --- Private Parsers ---
|
|
210
|
+
|
|
211
|
+
private parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
|
|
341
212
|
const name = node.name!.text
|
|
342
|
-
const id = this.allocateFnId(name)
|
|
343
|
-
const startLine = this.getLineNumber(node.getStart(this.sourceFile))
|
|
344
|
-
const endLine = this.getLineNumber(node.getEnd())
|
|
345
|
-
const params = this.extractParams(node.parameters)
|
|
346
|
-
const returnType = normalizeTypeAnnotation(node.type ? node.type.getText(this.sourceFile) : 'void')
|
|
347
|
-
const isAsync = !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
348
|
-
const isGenerator = !!node.asteriskToken
|
|
349
|
-
const typeParameters = this.extractTypeParameters(node.typeParameters)
|
|
350
|
-
const calls = this.extractCalls(node)
|
|
351
213
|
const bodyText = node.getText(this.sourceFile)
|
|
352
|
-
|
|
353
214
|
return {
|
|
354
|
-
id,
|
|
215
|
+
id: this.allocateId('fn', name),
|
|
355
216
|
name,
|
|
356
217
|
file: this.filePath,
|
|
357
|
-
startLine,
|
|
358
|
-
endLine,
|
|
359
|
-
params,
|
|
360
|
-
returnType,
|
|
218
|
+
startLine: this.getLineNumber(node.getStart(this.sourceFile)),
|
|
219
|
+
endLine: this.getLineNumber(node.getEnd()),
|
|
220
|
+
params: this.extractParams(node.parameters),
|
|
221
|
+
returnType: node.type ? node.type.getText(this.sourceFile) : 'void',
|
|
361
222
|
isExported: this.hasExportModifier(node),
|
|
362
|
-
isAsync,
|
|
363
|
-
|
|
364
|
-
...(typeParameters.length > 0 ? { typeParameters } : {}),
|
|
365
|
-
calls,
|
|
223
|
+
isAsync: !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword),
|
|
224
|
+
calls: this.extractCallsFromNode(node),
|
|
366
225
|
hash: hashContent(bodyText),
|
|
367
226
|
purpose: this.extractPurpose(node),
|
|
368
227
|
edgeCasesHandled: this.extractEdgeCases(node),
|
|
369
228
|
errorHandling: this.extractErrorHandling(node),
|
|
370
|
-
detailedLines:
|
|
229
|
+
detailedLines: [],
|
|
371
230
|
}
|
|
372
231
|
}
|
|
373
232
|
|
|
374
|
-
|
|
375
|
-
stmt: ts.VariableStatement,
|
|
376
|
-
decl: ts.VariableDeclaration,
|
|
377
|
-
fn: ts.ArrowFunction | ts.FunctionExpression
|
|
378
|
-
): ParsedFunction {
|
|
233
|
+
private parseVariableFunction(stmt: ts.VariableStatement, decl: ts.VariableDeclaration, fn: ts.ArrowFunction | ts.FunctionExpression): ParsedFunction {
|
|
379
234
|
const name = (decl.name as ts.Identifier).text
|
|
380
|
-
const id = this.allocateFnId(name)
|
|
381
|
-
const startLine = this.getLineNumber(stmt.getStart(this.sourceFile))
|
|
382
|
-
const endLine = this.getLineNumber(stmt.getEnd())
|
|
383
|
-
const params = this.extractParams(fn.parameters)
|
|
384
|
-
const returnType = normalizeTypeAnnotation(fn.type ? fn.type.getText(this.sourceFile) : 'void')
|
|
385
|
-
const isAsync = !!fn.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
386
|
-
const isGenerator = ts.isFunctionExpression(fn) && !!fn.asteriskToken
|
|
387
|
-
const typeParameters = this.extractTypeParameters(fn.typeParameters)
|
|
388
|
-
const calls = this.extractCalls(fn)
|
|
389
235
|
const bodyText = stmt.getText(this.sourceFile)
|
|
390
|
-
|
|
391
236
|
return {
|
|
392
|
-
id,
|
|
237
|
+
id: this.allocateId('fn', name),
|
|
393
238
|
name,
|
|
394
239
|
file: this.filePath,
|
|
395
|
-
startLine,
|
|
396
|
-
endLine,
|
|
397
|
-
params,
|
|
398
|
-
returnType,
|
|
240
|
+
startLine: this.getLineNumber(stmt.getStart(this.sourceFile)),
|
|
241
|
+
endLine: this.getLineNumber(stmt.getEnd()),
|
|
242
|
+
params: this.extractParams(fn.parameters),
|
|
243
|
+
returnType: fn.type ? fn.type.getText(this.sourceFile) : 'unknown',
|
|
399
244
|
isExported: this.hasExportModifier(stmt),
|
|
400
|
-
isAsync,
|
|
401
|
-
|
|
402
|
-
...(typeParameters.length > 0 ? { typeParameters } : {}),
|
|
403
|
-
calls,
|
|
245
|
+
isAsync: !!fn.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword),
|
|
246
|
+
calls: this.extractCallsFromNode(fn),
|
|
404
247
|
hash: hashContent(bodyText),
|
|
405
248
|
purpose: this.extractPurpose(stmt),
|
|
406
249
|
edgeCasesHandled: this.extractEdgeCases(fn),
|
|
407
250
|
errorHandling: this.extractErrorHandling(fn),
|
|
408
|
-
detailedLines:
|
|
251
|
+
detailedLines: [],
|
|
409
252
|
}
|
|
410
253
|
}
|
|
411
254
|
|
|
412
|
-
|
|
255
|
+
private parseClass(node: ts.ClassDeclaration): ParsedClass {
|
|
413
256
|
const name = node.name!.text
|
|
414
|
-
const startLine = this.getLineNumber(node.getStart(this.sourceFile))
|
|
415
|
-
const endLine = this.getLineNumber(node.getEnd())
|
|
416
257
|
const methods: ParsedFunction[] = []
|
|
417
|
-
const
|
|
418
|
-
const typeParameters = this.extractTypeParameters(node.typeParameters)
|
|
258
|
+
const properties: ParsedVariable[] = []
|
|
419
259
|
|
|
420
260
|
for (const member of node.members) {
|
|
421
|
-
if (ts.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const params = this.extractParams(member.parameters)
|
|
426
|
-
const calls = this.extractCalls(member)
|
|
427
|
-
const bodyText = member.getText(this.sourceFile)
|
|
428
|
-
const methodName = `${name}.constructor`
|
|
429
|
-
|
|
430
|
-
methods.push({
|
|
431
|
-
id: this.allocateFnId(methodName),
|
|
432
|
-
name: methodName,
|
|
433
|
-
file: this.filePath,
|
|
434
|
-
startLine: mStartLine,
|
|
435
|
-
endLine: mEndLine,
|
|
436
|
-
params,
|
|
437
|
-
returnType: name,
|
|
438
|
-
isExported: this.hasExportModifier(node),
|
|
439
|
-
isAsync: false,
|
|
440
|
-
calls,
|
|
441
|
-
hash: hashContent(bodyText),
|
|
442
|
-
purpose: this.extractPurpose(member),
|
|
443
|
-
edgeCasesHandled: this.extractEdgeCases(member),
|
|
444
|
-
errorHandling: this.extractErrorHandling(member),
|
|
445
|
-
detailedLines: this.extractDetailedLines(member),
|
|
446
|
-
})
|
|
447
|
-
} else if (ts.isMethodDeclaration(member) && member.name) {
|
|
448
|
-
const methodName = `${name}.${member.name.getText(this.sourceFile)}`
|
|
449
|
-
const mStartLine = this.getLineNumber(member.getStart(this.sourceFile))
|
|
450
|
-
const mEndLine = this.getLineNumber(member.getEnd())
|
|
451
|
-
const params = this.extractParams(member.parameters)
|
|
452
|
-
const returnType = normalizeTypeAnnotation(member.type ? member.type.getText(this.sourceFile) : 'void')
|
|
453
|
-
const isAsync = !!member.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
454
|
-
const isGenerator = !!member.asteriskToken
|
|
455
|
-
const methodTypeParams = this.extractTypeParameters(member.typeParameters)
|
|
456
|
-
const calls = this.extractCalls(member)
|
|
457
|
-
const bodyText = member.getText(this.sourceFile)
|
|
458
|
-
|
|
459
|
-
methods.push({
|
|
460
|
-
id: this.allocateFnId(methodName),
|
|
461
|
-
name: methodName,
|
|
462
|
-
file: this.filePath,
|
|
463
|
-
startLine: mStartLine,
|
|
464
|
-
endLine: mEndLine,
|
|
465
|
-
params,
|
|
466
|
-
returnType,
|
|
467
|
-
isExported: this.hasExportModifier(node),
|
|
468
|
-
isAsync,
|
|
469
|
-
...(isGenerator ? { isGenerator } : {}),
|
|
470
|
-
...(methodTypeParams.length > 0 ? { typeParameters: methodTypeParams } : {}),
|
|
471
|
-
calls,
|
|
472
|
-
hash: hashContent(bodyText),
|
|
473
|
-
purpose: this.extractPurpose(member),
|
|
474
|
-
edgeCasesHandled: this.extractEdgeCases(member),
|
|
475
|
-
errorHandling: this.extractErrorHandling(member),
|
|
476
|
-
detailedLines: this.extractDetailedLines(member),
|
|
477
|
-
})
|
|
261
|
+
if (ts.isMethodDeclaration(member) && member.name) {
|
|
262
|
+
methods.push(this.parseMethod(name, member))
|
|
263
|
+
} else if (ts.isPropertyDeclaration(member) && member.name) {
|
|
264
|
+
properties.push(this.parseProperty(name, member))
|
|
478
265
|
}
|
|
479
266
|
}
|
|
480
267
|
|
|
481
268
|
return {
|
|
482
|
-
id:
|
|
269
|
+
id: this.allocateId('class', name),
|
|
483
270
|
name,
|
|
484
271
|
file: this.filePath,
|
|
485
|
-
startLine,
|
|
486
|
-
endLine,
|
|
272
|
+
startLine: this.getLineNumber(node.getStart(this.sourceFile)),
|
|
273
|
+
endLine: this.getLineNumber(node.getEnd()),
|
|
487
274
|
methods,
|
|
275
|
+
properties,
|
|
276
|
+
extends: node.heritageClauses?.find(c => c.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]?.getText(this.sourceFile),
|
|
277
|
+
implements: node.heritageClauses?.find(c => c.token === ts.SyntaxKind.ImplementsKeyword)?.types.map(t => t.getText(this.sourceFile)),
|
|
488
278
|
isExported: this.hasExportModifier(node),
|
|
489
|
-
|
|
490
|
-
...(typeParameters.length > 0 ? { typeParameters } : {}),
|
|
279
|
+
hash: hashContent(node.getText(this.sourceFile)),
|
|
491
280
|
purpose: this.extractPurpose(node),
|
|
492
281
|
edgeCasesHandled: this.extractEdgeCases(node),
|
|
493
282
|
errorHandling: this.extractErrorHandling(node),
|
|
494
283
|
}
|
|
495
284
|
}
|
|
496
285
|
|
|
497
|
-
|
|
286
|
+
private parseMethod(className: string, node: ts.MethodDeclaration): ParsedFunction {
|
|
287
|
+
const methodName = node.name.getText(this.sourceFile)
|
|
288
|
+
const fullName = `${className}.${methodName}`
|
|
289
|
+
return {
|
|
290
|
+
id: this.allocateId('fn', fullName),
|
|
291
|
+
name: fullName,
|
|
292
|
+
file: this.filePath,
|
|
293
|
+
startLine: this.getLineNumber(node.getStart(this.sourceFile)),
|
|
294
|
+
endLine: this.getLineNumber(node.getEnd()),
|
|
295
|
+
params: this.extractParams(node.parameters),
|
|
296
|
+
returnType: node.type ? node.type.getText(this.sourceFile) : 'void',
|
|
297
|
+
isExported: false,
|
|
298
|
+
isAsync: !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword),
|
|
299
|
+
calls: this.extractCallsFromNode(node),
|
|
300
|
+
hash: hashContent(node.getText(this.sourceFile)),
|
|
301
|
+
purpose: this.extractPurpose(node),
|
|
302
|
+
edgeCasesHandled: this.extractEdgeCases(node),
|
|
303
|
+
errorHandling: this.extractErrorHandling(node),
|
|
304
|
+
detailedLines: [],
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private parseProperty(className: string, node: ts.PropertyDeclaration): ParsedVariable {
|
|
309
|
+
const propName = node.name.getText(this.sourceFile)
|
|
310
|
+
return {
|
|
311
|
+
id: this.allocateId('var', `${className}.${propName}`),
|
|
312
|
+
name: propName,
|
|
313
|
+
type: node.type ? node.type.getText(this.sourceFile) : 'any',
|
|
314
|
+
file: this.filePath,
|
|
315
|
+
line: this.getLineNumber(node.getStart(this.sourceFile)),
|
|
316
|
+
isExported: false,
|
|
317
|
+
isStatic: !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private parseVariable(stmt: ts.VariableStatement, decl: ts.VariableDeclaration): ParsedVariable {
|
|
322
|
+
const name = (decl.name as ts.Identifier).text
|
|
323
|
+
return {
|
|
324
|
+
id: this.allocateId('var', name),
|
|
325
|
+
name,
|
|
326
|
+
type: decl.type ? decl.type.getText(this.sourceFile) : 'any',
|
|
327
|
+
file: this.filePath,
|
|
328
|
+
line: this.getLineNumber(stmt.getStart(this.sourceFile)),
|
|
329
|
+
isExported: this.hasExportModifier(stmt),
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private parseImport(node: ts.ImportDeclaration): ParsedImport {
|
|
498
334
|
const source = (node.moduleSpecifier as ts.StringLiteral).text
|
|
499
335
|
const names: string[] = []
|
|
500
336
|
let isDefault = false
|
|
501
|
-
|
|
502
337
|
if (node.importClause) {
|
|
503
|
-
// Skip type-only imports: import type { Foo } or import type * as X
|
|
504
|
-
if (node.importClause.isTypeOnly) return null
|
|
505
|
-
|
|
506
|
-
// import Foo from './module' (default import)
|
|
507
338
|
if (node.importClause.name) {
|
|
508
339
|
names.push(node.importClause.name.text)
|
|
509
340
|
isDefault = true
|
|
510
341
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (ts.isNamedImports(node.importClause.namedBindings)) {
|
|
514
|
-
for (const element of node.importClause.namedBindings.elements) {
|
|
515
|
-
// Skip individual type-only elements: import { type Foo }
|
|
516
|
-
if (!element.isTypeOnly) {
|
|
517
|
-
names.push(element.name.text)
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
// import * as foo from './module'
|
|
522
|
-
if (ts.isNamespaceImport(node.importClause.namedBindings)) {
|
|
523
|
-
names.push(node.importClause.namedBindings.name.text)
|
|
524
|
-
}
|
|
342
|
+
if (node.importClause.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
|
|
343
|
+
node.importClause.namedBindings.elements.forEach(el => names.push(el.name.text))
|
|
525
344
|
}
|
|
526
345
|
}
|
|
527
|
-
|
|
528
|
-
return {
|
|
529
|
-
source,
|
|
530
|
-
resolvedPath: '', // Filled in by resolver
|
|
531
|
-
names,
|
|
532
|
-
isDefault,
|
|
533
|
-
isDynamic: false,
|
|
534
|
-
}
|
|
346
|
+
return { source, resolvedPath: '', names, isDefault, isDynamic: false }
|
|
535
347
|
}
|
|
536
348
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
*
|
|
543
|
-
* Strategy: walk only the body block, not the full node subtree.
|
|
544
|
-
*/
|
|
545
|
-
protected extractCalls(node: ts.Node): string[] {
|
|
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
|
-
|
|
552
|
-
const walkCalls = (n: ts.Node) => {
|
|
349
|
+
// --- Helpers ---
|
|
350
|
+
|
|
351
|
+
protected extractCallsFromNode(node: ts.Node): CallExpression[] {
|
|
352
|
+
const calls: CallExpression[] = []
|
|
353
|
+
const walk = (n: ts.Node) => {
|
|
553
354
|
if (ts.isCallExpression(n)) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
355
|
+
calls.push({
|
|
356
|
+
name: n.expression.getText(this.sourceFile),
|
|
357
|
+
line: this.getLineNumber(n.getStart(this.sourceFile)),
|
|
358
|
+
type: ts.isPropertyAccessExpression(n.expression) ? 'method' : 'function'
|
|
359
|
+
})
|
|
360
|
+
} else if (ts.isNewExpression(n)) {
|
|
361
|
+
calls.push({
|
|
362
|
+
name: n.expression.getText(this.sourceFile),
|
|
363
|
+
line: this.getLineNumber(n.getStart(this.sourceFile)),
|
|
364
|
+
type: 'function'
|
|
365
|
+
})
|
|
366
|
+
} else if (ts.isPropertyAccessExpression(n) && !ts.isCallExpression(n.parent)) {
|
|
367
|
+
// Property access that isn't a call
|
|
368
|
+
calls.push({
|
|
369
|
+
name: n.getText(this.sourceFile),
|
|
370
|
+
line: this.getLineNumber(n.getStart(this.sourceFile)),
|
|
371
|
+
type: 'property'
|
|
372
|
+
})
|
|
570
373
|
}
|
|
571
|
-
ts.forEachChild(n,
|
|
374
|
+
ts.forEachChild(n, walk)
|
|
572
375
|
}
|
|
573
|
-
|
|
574
|
-
return
|
|
376
|
+
walk(node)
|
|
377
|
+
return calls
|
|
575
378
|
}
|
|
576
379
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const comment = fullText.slice(range.pos, range.end)
|
|
586
|
-
let clean = ''
|
|
587
|
-
if (comment.startsWith('/**') || comment.startsWith('/*')) {
|
|
588
|
-
clean = comment
|
|
589
|
-
.replace(/^\/\*+/, '') // remove leading /* or /**
|
|
590
|
-
.replace(/\*+\/$/, '') // remove trailing */
|
|
591
|
-
.replace(/^\s*\*+\s?/gm, '') // remove leading * on each line
|
|
592
|
-
.trim()
|
|
593
|
-
} else if (comment.startsWith('//')) {
|
|
594
|
-
clean = comment.replace(/^\/\/+\s?/, '').trim()
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Skip divider lines (lines with 3+ repeated special characters)
|
|
598
|
-
if (/^[\-_=\*]{3,}$/.test(clean)) continue
|
|
380
|
+
protected extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParsedParam[] {
|
|
381
|
+
return params.map(p => ({
|
|
382
|
+
name: p.name.getText(this.sourceFile),
|
|
383
|
+
type: p.type ? p.type.getText(this.sourceFile) : 'any',
|
|
384
|
+
optional: !!p.questionToken || !!p.initializer,
|
|
385
|
+
defaultValue: p.initializer ? p.initializer.getText(this.sourceFile) : undefined,
|
|
386
|
+
}))
|
|
387
|
+
}
|
|
599
388
|
|
|
600
|
-
|
|
601
|
-
|
|
389
|
+
protected extractTypeParameters(typeParams?: ts.NodeArray<ts.TypeParameterDeclaration>): string[] {
|
|
390
|
+
return typeParams?.map(t => t.name.text) || []
|
|
391
|
+
}
|
|
602
392
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
393
|
+
protected hasExportModifier(node: ts.Node): boolean {
|
|
394
|
+
return !!ts.getModifiers(node as any)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
395
|
+
}
|
|
606
396
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
return name ? derivePurposeFromName(name) : ''
|
|
397
|
+
protected getLineNumber(pos: number): number {
|
|
398
|
+
return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
|
|
610
399
|
}
|
|
611
400
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
if (
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
return parent.name.text
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) return node.name.text
|
|
622
|
-
if (ts.isConstructorDeclaration(node)) return 'constructor'
|
|
623
|
-
if ((ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
|
|
624
|
-
return (node as ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.ClassDeclaration).name!.text
|
|
401
|
+
protected extractPurpose(node: ts.Node): string {
|
|
402
|
+
const fullText = this.sourceFile.getFullText()
|
|
403
|
+
const ranges = ts.getLeadingCommentRanges(fullText, node.pos)
|
|
404
|
+
if (ranges && ranges.length > 0) {
|
|
405
|
+
const lastComment = fullText.slice(ranges[ranges.length - 1].pos, ranges[ranges.length - 1].end)
|
|
406
|
+
return lastComment.replace(/\/\*+|\*+\/|\/\/+/g, '').trim()
|
|
625
407
|
}
|
|
626
408
|
return ''
|
|
627
409
|
}
|
|
628
410
|
|
|
629
|
-
/** Extract edge cases handled (if statements returning early) */
|
|
630
411
|
protected extractEdgeCases(node: ts.Node): string[] {
|
|
631
412
|
const edgeCases: string[] = []
|
|
632
|
-
const
|
|
633
|
-
if (ts.isIfStatement(n)) {
|
|
634
|
-
|
|
635
|
-
if (
|
|
636
|
-
ts.isReturnStatement(n.thenStatement) ||
|
|
637
|
-
(ts.isBlock(n.thenStatement) && n.thenStatement.statements.some(ts.isReturnStatement)) ||
|
|
638
|
-
ts.isThrowStatement(n.thenStatement) ||
|
|
639
|
-
(ts.isBlock(n.thenStatement) && n.thenStatement.statements.some(ts.isThrowStatement))
|
|
640
|
-
) {
|
|
641
|
-
edgeCases.push(n.expression.getText(this.sourceFile))
|
|
642
|
-
}
|
|
413
|
+
const walk = (n: ts.Node) => {
|
|
414
|
+
if (ts.isIfStatement(n) || ts.isConditionalExpression(n)) {
|
|
415
|
+
edgeCases.push(n.getText(this.sourceFile).split('{')[0].trim())
|
|
643
416
|
}
|
|
644
|
-
ts.forEachChild(n,
|
|
417
|
+
ts.forEachChild(n, walk)
|
|
645
418
|
}
|
|
646
|
-
|
|
419
|
+
walk(node)
|
|
647
420
|
return edgeCases
|
|
648
421
|
}
|
|
649
422
|
|
|
650
|
-
/** Extract try-catch blocks or explicit throw statements */
|
|
651
423
|
protected extractErrorHandling(node: ts.Node): { line: number, type: 'try-catch' | 'throw', detail: string }[] {
|
|
652
|
-
const
|
|
653
|
-
const
|
|
424
|
+
const result: { line: number, type: 'try-catch' | 'throw', detail: string }[] = []
|
|
425
|
+
const walk = (n: ts.Node) => {
|
|
654
426
|
if (ts.isTryStatement(n)) {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
detail: 'try-catch block'
|
|
659
|
-
})
|
|
660
|
-
}
|
|
661
|
-
if (ts.isThrowStatement(n)) {
|
|
662
|
-
errors.push({
|
|
663
|
-
line: this.getLineNumber(n.getStart(this.sourceFile)),
|
|
664
|
-
type: 'throw',
|
|
665
|
-
detail: n.expression ? n.expression.getText(this.sourceFile) : 'throw error'
|
|
666
|
-
})
|
|
667
|
-
}
|
|
668
|
-
ts.forEachChild(n, walkErrors)
|
|
669
|
-
}
|
|
670
|
-
ts.forEachChild(node, walkErrors)
|
|
671
|
-
return errors
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/** Extract detailed line block breakdowns */
|
|
675
|
-
protected extractDetailedLines(node: ts.Node): { startLine: number, endLine: number, blockType: string }[] {
|
|
676
|
-
const blocks: { startLine: number, endLine: number, blockType: string }[] = []
|
|
677
|
-
const walkBlocks = (n: ts.Node) => {
|
|
678
|
-
if (ts.isIfStatement(n) || ts.isSwitchStatement(n)) {
|
|
679
|
-
blocks.push({
|
|
680
|
-
startLine: this.getLineNumber(n.getStart(this.sourceFile)),
|
|
681
|
-
endLine: this.getLineNumber(n.getEnd()),
|
|
682
|
-
blockType: 'ControlFlow'
|
|
683
|
-
})
|
|
684
|
-
} else if (ts.isForStatement(n) || ts.isWhileStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n)) {
|
|
685
|
-
blocks.push({
|
|
686
|
-
startLine: this.getLineNumber(n.getStart(this.sourceFile)),
|
|
687
|
-
endLine: this.getLineNumber(n.getEnd()),
|
|
688
|
-
blockType: 'Loop'
|
|
689
|
-
})
|
|
427
|
+
result.push({ line: this.getLineNumber(n.getStart(this.sourceFile)), type: 'try-catch', detail: 'try block' })
|
|
428
|
+
} else if (ts.isThrowStatement(n)) {
|
|
429
|
+
result.push({ line: this.getLineNumber(n.getStart(this.sourceFile)), type: 'throw', detail: n.expression?.getText(this.sourceFile) || 'unknown' })
|
|
690
430
|
}
|
|
691
|
-
ts.forEachChild(n,
|
|
431
|
+
ts.forEachChild(n, walk)
|
|
692
432
|
}
|
|
693
|
-
|
|
694
|
-
return
|
|
433
|
+
walk(node)
|
|
434
|
+
return result
|
|
695
435
|
}
|
|
696
436
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
if (!typeParams || typeParams.length === 0) return []
|
|
700
|
-
return typeParams.map(tp => tp.name.text)
|
|
437
|
+
protected extractDetailedLines(node: ts.Node): { startLine: number; endLine: number; blockType: string }[] {
|
|
438
|
+
return [] // Implementation for behavioral tracking
|
|
701
439
|
}
|
|
702
440
|
|
|
703
|
-
|
|
704
|
-
protected extractDecorators(node: ts.ClassDeclaration): string[] {
|
|
705
|
-
const decorators: string[] = []
|
|
706
|
-
const modifiers = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined
|
|
707
|
-
if (modifiers) {
|
|
708
|
-
for (const decorator of modifiers) {
|
|
709
|
-
if (ts.isCallExpression(decorator.expression)) {
|
|
710
|
-
// @Injectable() decorator with arguments
|
|
711
|
-
decorators.push(decorator.expression.expression.getText(this.sourceFile))
|
|
712
|
-
} else if (ts.isIdentifier(decorator.expression)) {
|
|
713
|
-
// @Sealed decorator without arguments
|
|
714
|
-
decorators.push(decorator.expression.text)
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
return decorators
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/** Extract parameters from a function's parameter list */
|
|
722
|
-
protected extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParsedParam[] {
|
|
723
|
-
return params.map((p) => ({
|
|
724
|
-
name: p.name.getText(this.sourceFile),
|
|
725
|
-
type: normalizeTypeAnnotation(p.type ? p.type.getText(this.sourceFile) : 'any'),
|
|
726
|
-
optional: !!p.questionToken || !!p.initializer,
|
|
727
|
-
defaultValue: p.initializer ? p.initializer.getText(this.sourceFile) : undefined,
|
|
728
|
-
}))
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/** Check if a node has the 'export' modifier */
|
|
732
|
-
protected hasExportModifier(node: ts.Node): boolean {
|
|
733
|
-
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
|
|
734
|
-
return !!modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/** Get 1-indexed line number from a character position */
|
|
738
|
-
protected getLineNumber(pos: number): number {
|
|
739
|
-
return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
|
|
740
|
-
}
|
|
741
|
-
|
|
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
|
-
*/
|
|
750
|
-
protected walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
|
|
441
|
+
private walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
|
|
751
442
|
ts.forEachChild(node, (child) => {
|
|
752
443
|
callback(child)
|
|
753
444
|
this.walkNode(child, callback)
|
|
754
445
|
})
|
|
755
446
|
}
|
|
756
447
|
}
|
|
757
|
-
|
|
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
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* Derive a human-readable purpose sentence from a camelCase/PascalCase identifier.
|
|
784
|
-
* Examples:
|
|
785
|
-
* validateJwtToken -> "Validate jwt token"
|
|
786
|
-
* buildGraphFromLock -> "Build graph from lock"
|
|
787
|
-
* UserRepository -> "User repository"
|
|
788
|
-
* parseFiles -> "Parse files"
|
|
789
|
-
*/
|
|
790
|
-
function normalizeTypeAnnotation(type: string): string {
|
|
791
|
-
return type.replace(/\s*\n\s*/g, ' ').replace(/\s{2,}/g, ' ').trim()
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
function derivePurposeFromName(name: string): string {
|
|
795
|
-
if (!name || name === 'constructor') return ''
|
|
796
|
-
// Split on camelCase/PascalCase boundaries and underscores
|
|
797
|
-
const words = name
|
|
798
|
-
.replace(/_+/g, ' ')
|
|
799
|
-
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
800
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
801
|
-
.toLowerCase()
|
|
802
|
-
.split(/\s+/)
|
|
803
|
-
.filter(Boolean)
|
|
804
|
-
if (words.length === 0) return ''
|
|
805
|
-
words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1)
|
|
806
|
-
return words.join(' ')
|
|
807
|
-
}
|