@getmikk/core 1.2.0 → 1.3.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/README.md +431 -0
- package/package.json +6 -2
- package/src/contract/contract-generator.ts +85 -85
- package/src/contract/contract-reader.ts +28 -28
- package/src/contract/contract-writer.ts +114 -114
- package/src/contract/index.ts +12 -12
- package/src/contract/lock-compiler.ts +221 -221
- package/src/contract/lock-reader.ts +34 -34
- package/src/contract/schema.ts +147 -147
- package/src/graph/cluster-detector.ts +312 -312
- package/src/graph/graph-builder.ts +211 -211
- package/src/graph/impact-analyzer.ts +55 -55
- package/src/graph/index.ts +4 -4
- package/src/graph/types.ts +59 -59
- package/src/hash/file-hasher.ts +30 -30
- package/src/hash/hash-store.ts +119 -119
- package/src/hash/index.ts +3 -3
- package/src/hash/tree-hasher.ts +20 -20
- package/src/index.ts +12 -12
- package/src/parser/base-parser.ts +16 -16
- package/src/parser/boundary-checker.ts +211 -211
- package/src/parser/index.ts +46 -46
- package/src/parser/types.ts +90 -90
- package/src/parser/typescript/ts-extractor.ts +543 -543
- package/src/parser/typescript/ts-parser.ts +41 -41
- package/src/parser/typescript/ts-resolver.ts +86 -86
- package/src/utils/errors.ts +42 -42
- package/src/utils/fs.ts +75 -75
- package/src/utils/fuzzy-match.ts +186 -186
- package/src/utils/logger.ts +36 -36
- package/src/utils/minimatch.ts +19 -19
- package/tests/contract.test.ts +134 -134
- package/tests/fixtures/simple-api/package.json +5 -5
- package/tests/fixtures/simple-api/src/auth/middleware.ts +9 -9
- package/tests/fixtures/simple-api/src/auth/verify.ts +6 -6
- package/tests/fixtures/simple-api/src/index.ts +9 -9
- package/tests/fixtures/simple-api/src/utils/jwt.ts +3 -3
- package/tests/fixtures/simple-api/tsconfig.json +8 -8
- package/tests/fuzzy-match.test.ts +142 -142
- package/tests/graph.test.ts +169 -169
- package/tests/hash.test.ts +49 -49
- package/tests/helpers.ts +83 -83
- package/tests/parser.test.ts +218 -218
- package/tsconfig.json +15 -15
|
@@ -1,543 +1,543 @@
|
|
|
1
|
-
import ts from 'typescript'
|
|
2
|
-
import type { ParsedFunction, ParsedClass, ParsedImport, ParsedExport, ParsedParam, ParsedGeneric } from '../types.js'
|
|
3
|
-
import { hashContent } from '../../hash/file-hasher.js'
|
|
4
|
-
|
|
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
|
-
export class TypeScriptExtractor {
|
|
10
|
-
private sourceFile: ts.SourceFile
|
|
11
|
-
|
|
12
|
-
constructor(
|
|
13
|
-
private filePath: string,
|
|
14
|
-
private content: string
|
|
15
|
-
) {
|
|
16
|
-
this.sourceFile = ts.createSourceFile(
|
|
17
|
-
filePath,
|
|
18
|
-
content,
|
|
19
|
-
ts.ScriptTarget.Latest,
|
|
20
|
-
true, // setParentNodes
|
|
21
|
-
filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
22
|
-
)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Extract all top-level and variable-assigned functions */
|
|
26
|
-
extractFunctions(): ParsedFunction[] {
|
|
27
|
-
const functions: ParsedFunction[] = []
|
|
28
|
-
this.walkNode(this.sourceFile, (node) => {
|
|
29
|
-
// function declarations: function foo() {}
|
|
30
|
-
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
31
|
-
functions.push(this.parseFunctionDeclaration(node))
|
|
32
|
-
}
|
|
33
|
-
// variable declarations with arrow functions or function expressions:
|
|
34
|
-
// const foo = () => {} or const foo = function() {}
|
|
35
|
-
if (ts.isVariableStatement(node)) {
|
|
36
|
-
for (const decl of node.declarationList.declarations) {
|
|
37
|
-
if (decl.initializer && ts.isIdentifier(decl.name)) {
|
|
38
|
-
if (
|
|
39
|
-
ts.isArrowFunction(decl.initializer) ||
|
|
40
|
-
ts.isFunctionExpression(decl.initializer)
|
|
41
|
-
) {
|
|
42
|
-
functions.push(this.parseVariableFunction(node, decl, decl.initializer))
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
})
|
|
48
|
-
return functions
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Extract all class declarations */
|
|
52
|
-
extractClasses(): ParsedClass[] {
|
|
53
|
-
const classes: ParsedClass[] = []
|
|
54
|
-
this.walkNode(this.sourceFile, (node) => {
|
|
55
|
-
if (ts.isClassDeclaration(node) && node.name) {
|
|
56
|
-
classes.push(this.parseClass(node))
|
|
57
|
-
}
|
|
58
|
-
})
|
|
59
|
-
return classes
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Extract generic declarations (interfaces, types, constants with metadata) */
|
|
63
|
-
extractGenerics(): ParsedGeneric[] {
|
|
64
|
-
const generics: ParsedGeneric[] = []
|
|
65
|
-
this.walkNode(this.sourceFile, (node) => {
|
|
66
|
-
if (ts.isInterfaceDeclaration(node)) {
|
|
67
|
-
const tp = this.extractTypeParameters(node.typeParameters)
|
|
68
|
-
generics.push({
|
|
69
|
-
id: `intf:${this.filePath}:${node.name.text}`,
|
|
70
|
-
name: node.name.text,
|
|
71
|
-
type: 'interface',
|
|
72
|
-
file: this.filePath,
|
|
73
|
-
startLine: this.getLineNumber(node.getStart()),
|
|
74
|
-
endLine: this.getLineNumber(node.getEnd()),
|
|
75
|
-
isExported: this.hasExportModifier(node),
|
|
76
|
-
...(tp.length > 0 ? { typeParameters: tp } : {}),
|
|
77
|
-
purpose: this.extractPurpose(node),
|
|
78
|
-
})
|
|
79
|
-
} else if (ts.isTypeAliasDeclaration(node)) {
|
|
80
|
-
const tp = this.extractTypeParameters(node.typeParameters)
|
|
81
|
-
generics.push({
|
|
82
|
-
id: `type:${this.filePath}:${node.name.text}`,
|
|
83
|
-
name: node.name.text,
|
|
84
|
-
type: 'type',
|
|
85
|
-
file: this.filePath,
|
|
86
|
-
startLine: this.getLineNumber(node.getStart()),
|
|
87
|
-
endLine: this.getLineNumber(node.getEnd()),
|
|
88
|
-
isExported: this.hasExportModifier(node),
|
|
89
|
-
...(tp.length > 0 ? { typeParameters: tp } : {}),
|
|
90
|
-
purpose: this.extractPurpose(node),
|
|
91
|
-
})
|
|
92
|
-
} else if (ts.isVariableStatement(node) && !this.isVariableFunction(node)) {
|
|
93
|
-
// top-level constants (not functions)
|
|
94
|
-
for (const decl of node.declarationList.declarations) {
|
|
95
|
-
if (ts.isIdentifier(decl.name)) {
|
|
96
|
-
generics.push({
|
|
97
|
-
id: `const:${this.filePath}:${decl.name.text}`,
|
|
98
|
-
name: decl.name.text,
|
|
99
|
-
type: 'const',
|
|
100
|
-
file: this.filePath,
|
|
101
|
-
startLine: this.getLineNumber(node.getStart()),
|
|
102
|
-
endLine: this.getLineNumber(node.getEnd()),
|
|
103
|
-
isExported: this.hasExportModifier(node),
|
|
104
|
-
purpose: this.extractPurpose(node),
|
|
105
|
-
})
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
})
|
|
110
|
-
return generics
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
private isVariableFunction(node: ts.VariableStatement): boolean {
|
|
114
|
-
for (const decl of node.declarationList.declarations) {
|
|
115
|
-
if (decl.initializer && (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))) {
|
|
116
|
-
return true
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return false
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Extract all import statements */
|
|
123
|
-
extractImports(): ParsedImport[] {
|
|
124
|
-
const imports: ParsedImport[] = []
|
|
125
|
-
this.walkNode(this.sourceFile, (node) => {
|
|
126
|
-
if (ts.isImportDeclaration(node)) {
|
|
127
|
-
const parsed = this.parseImport(node)
|
|
128
|
-
if (parsed) imports.push(parsed)
|
|
129
|
-
}
|
|
130
|
-
})
|
|
131
|
-
return imports
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Extract all exported symbols */
|
|
135
|
-
extractExports(): ParsedExport[] {
|
|
136
|
-
const exports: ParsedExport[] = []
|
|
137
|
-
this.walkNode(this.sourceFile, (node) => {
|
|
138
|
-
// export function foo() {}
|
|
139
|
-
if (ts.isFunctionDeclaration(node) && node.name && this.hasExportModifier(node)) {
|
|
140
|
-
exports.push({
|
|
141
|
-
name: node.name.text,
|
|
142
|
-
type: 'function',
|
|
143
|
-
file: this.filePath,
|
|
144
|
-
})
|
|
145
|
-
}
|
|
146
|
-
// export class Foo {}
|
|
147
|
-
if (ts.isClassDeclaration(node) && node.name && this.hasExportModifier(node)) {
|
|
148
|
-
exports.push({
|
|
149
|
-
name: node.name.text,
|
|
150
|
-
type: 'class',
|
|
151
|
-
file: this.filePath,
|
|
152
|
-
})
|
|
153
|
-
}
|
|
154
|
-
// export const foo = ...
|
|
155
|
-
if (ts.isVariableStatement(node) && this.hasExportModifier(node)) {
|
|
156
|
-
for (const decl of node.declarationList.declarations) {
|
|
157
|
-
if (ts.isIdentifier(decl.name)) {
|
|
158
|
-
const type = decl.initializer &&
|
|
159
|
-
(ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))
|
|
160
|
-
? 'function' : 'const'
|
|
161
|
-
exports.push({
|
|
162
|
-
name: decl.name.text,
|
|
163
|
-
type: type as ParsedExport['type'],
|
|
164
|
-
file: this.filePath,
|
|
165
|
-
})
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
// export interface Foo {}
|
|
170
|
-
if (ts.isInterfaceDeclaration(node) && this.hasExportModifier(node)) {
|
|
171
|
-
exports.push({
|
|
172
|
-
name: node.name.text,
|
|
173
|
-
type: 'interface',
|
|
174
|
-
file: this.filePath,
|
|
175
|
-
})
|
|
176
|
-
}
|
|
177
|
-
// export type Foo = ...
|
|
178
|
-
if (ts.isTypeAliasDeclaration(node) && this.hasExportModifier(node)) {
|
|
179
|
-
exports.push({
|
|
180
|
-
name: node.name.text,
|
|
181
|
-
type: 'type',
|
|
182
|
-
file: this.filePath,
|
|
183
|
-
})
|
|
184
|
-
}
|
|
185
|
-
// export default ...
|
|
186
|
-
if (ts.isExportAssignment(node)) {
|
|
187
|
-
const name = node.expression && ts.isIdentifier(node.expression) ? node.expression.text : 'default'
|
|
188
|
-
exports.push({
|
|
189
|
-
name,
|
|
190
|
-
type: 'default',
|
|
191
|
-
file: this.filePath,
|
|
192
|
-
})
|
|
193
|
-
}
|
|
194
|
-
// export { foo, bar } from './module'
|
|
195
|
-
if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
196
|
-
for (const element of node.exportClause.elements) {
|
|
197
|
-
exports.push({
|
|
198
|
-
name: element.name.text,
|
|
199
|
-
type: 'const',
|
|
200
|
-
file: this.filePath,
|
|
201
|
-
})
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
})
|
|
205
|
-
return exports
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// ─── Private Helpers ──────────────────────────────────────
|
|
209
|
-
|
|
210
|
-
private parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
|
|
211
|
-
const name = node.name!.text
|
|
212
|
-
const startLine = this.getLineNumber(node.getStart())
|
|
213
|
-
const endLine = this.getLineNumber(node.getEnd())
|
|
214
|
-
const params = this.extractParams(node.parameters)
|
|
215
|
-
const returnType = node.type ? node.type.getText(this.sourceFile) : 'void'
|
|
216
|
-
const isAsync = !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
217
|
-
const isGenerator = !!node.asteriskToken
|
|
218
|
-
const typeParameters = this.extractTypeParameters(node.typeParameters)
|
|
219
|
-
const calls = this.extractCalls(node)
|
|
220
|
-
const bodyText = node.getText(this.sourceFile)
|
|
221
|
-
|
|
222
|
-
return {
|
|
223
|
-
id: `fn:${this.filePath}:${name}`,
|
|
224
|
-
name,
|
|
225
|
-
file: this.filePath,
|
|
226
|
-
startLine,
|
|
227
|
-
endLine,
|
|
228
|
-
params,
|
|
229
|
-
returnType,
|
|
230
|
-
isExported: this.hasExportModifier(node),
|
|
231
|
-
isAsync,
|
|
232
|
-
...(isGenerator ? { isGenerator } : {}),
|
|
233
|
-
...(typeParameters.length > 0 ? { typeParameters } : {}),
|
|
234
|
-
calls,
|
|
235
|
-
hash: hashContent(bodyText),
|
|
236
|
-
purpose: this.extractPurpose(node),
|
|
237
|
-
edgeCasesHandled: this.extractEdgeCases(node),
|
|
238
|
-
errorHandling: this.extractErrorHandling(node),
|
|
239
|
-
detailedLines: this.extractDetailedLines(node),
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
private parseVariableFunction(
|
|
244
|
-
stmt: ts.VariableStatement,
|
|
245
|
-
decl: ts.VariableDeclaration,
|
|
246
|
-
fn: ts.ArrowFunction | ts.FunctionExpression
|
|
247
|
-
): ParsedFunction {
|
|
248
|
-
const name = (decl.name as ts.Identifier).text
|
|
249
|
-
const startLine = this.getLineNumber(stmt.getStart())
|
|
250
|
-
const endLine = this.getLineNumber(stmt.getEnd())
|
|
251
|
-
const params = this.extractParams(fn.parameters)
|
|
252
|
-
const returnType = fn.type ? fn.type.getText(this.sourceFile) : 'void'
|
|
253
|
-
const isAsync = !!fn.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
254
|
-
const isGenerator = ts.isFunctionExpression(fn) && !!fn.asteriskToken
|
|
255
|
-
const typeParameters = this.extractTypeParameters(fn.typeParameters)
|
|
256
|
-
const calls = this.extractCalls(fn)
|
|
257
|
-
const bodyText = stmt.getText(this.sourceFile)
|
|
258
|
-
|
|
259
|
-
return {
|
|
260
|
-
id: `fn:${this.filePath}:${name}`,
|
|
261
|
-
name,
|
|
262
|
-
file: this.filePath,
|
|
263
|
-
startLine,
|
|
264
|
-
endLine,
|
|
265
|
-
params,
|
|
266
|
-
returnType,
|
|
267
|
-
isExported: this.hasExportModifier(stmt),
|
|
268
|
-
isAsync,
|
|
269
|
-
...(isGenerator ? { isGenerator } : {}),
|
|
270
|
-
...(typeParameters.length > 0 ? { typeParameters } : {}),
|
|
271
|
-
calls,
|
|
272
|
-
hash: hashContent(bodyText),
|
|
273
|
-
purpose: this.extractPurpose(stmt),
|
|
274
|
-
edgeCasesHandled: this.extractEdgeCases(fn),
|
|
275
|
-
errorHandling: this.extractErrorHandling(fn),
|
|
276
|
-
detailedLines: this.extractDetailedLines(fn),
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
private parseClass(node: ts.ClassDeclaration): ParsedClass {
|
|
281
|
-
const name = node.name!.text
|
|
282
|
-
const startLine = this.getLineNumber(node.getStart())
|
|
283
|
-
const endLine = this.getLineNumber(node.getEnd())
|
|
284
|
-
const methods: ParsedFunction[] = []
|
|
285
|
-
const decorators = this.extractDecorators(node)
|
|
286
|
-
const typeParameters = this.extractTypeParameters(node.typeParameters)
|
|
287
|
-
|
|
288
|
-
for (const member of node.members) {
|
|
289
|
-
if (ts.isMethodDeclaration(member) && member.name) {
|
|
290
|
-
const methodName = member.name.getText(this.sourceFile)
|
|
291
|
-
const mStartLine = this.getLineNumber(member.getStart())
|
|
292
|
-
const mEndLine = this.getLineNumber(member.getEnd())
|
|
293
|
-
const params = this.extractParams(member.parameters)
|
|
294
|
-
const returnType = member.type ? member.type.getText(this.sourceFile) : 'void'
|
|
295
|
-
const isAsync = !!member.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
296
|
-
const isGenerator = !!member.asteriskToken
|
|
297
|
-
const methodTypeParams = this.extractTypeParameters(member.typeParameters)
|
|
298
|
-
const calls = this.extractCalls(member)
|
|
299
|
-
const bodyText = member.getText(this.sourceFile)
|
|
300
|
-
|
|
301
|
-
methods.push({
|
|
302
|
-
id: `fn:${this.filePath}:${name}.${methodName}`,
|
|
303
|
-
name: `${name}.${methodName}`,
|
|
304
|
-
file: this.filePath,
|
|
305
|
-
startLine: mStartLine,
|
|
306
|
-
endLine: mEndLine,
|
|
307
|
-
params,
|
|
308
|
-
returnType,
|
|
309
|
-
isExported: this.hasExportModifier(node),
|
|
310
|
-
isAsync,
|
|
311
|
-
...(isGenerator ? { isGenerator } : {}),
|
|
312
|
-
...(methodTypeParams.length > 0 ? { typeParameters: methodTypeParams } : {}),
|
|
313
|
-
calls,
|
|
314
|
-
hash: hashContent(bodyText),
|
|
315
|
-
purpose: this.extractPurpose(member),
|
|
316
|
-
edgeCasesHandled: this.extractEdgeCases(member),
|
|
317
|
-
errorHandling: this.extractErrorHandling(member),
|
|
318
|
-
detailedLines: this.extractDetailedLines(member),
|
|
319
|
-
})
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return {
|
|
324
|
-
id: `class:${this.filePath}:${name}`,
|
|
325
|
-
name,
|
|
326
|
-
file: this.filePath,
|
|
327
|
-
startLine,
|
|
328
|
-
endLine,
|
|
329
|
-
methods,
|
|
330
|
-
isExported: this.hasExportModifier(node),
|
|
331
|
-
...(decorators.length > 0 ? { decorators } : {}),
|
|
332
|
-
...(typeParameters.length > 0 ? { typeParameters } : {}),
|
|
333
|
-
purpose: this.extractPurpose(node),
|
|
334
|
-
edgeCasesHandled: this.extractEdgeCases(node),
|
|
335
|
-
errorHandling: this.extractErrorHandling(node),
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
private parseImport(node: ts.ImportDeclaration): ParsedImport | null {
|
|
340
|
-
const source = (node.moduleSpecifier as ts.StringLiteral).text
|
|
341
|
-
const names: string[] = []
|
|
342
|
-
let isDefault = false
|
|
343
|
-
|
|
344
|
-
if (node.importClause) {
|
|
345
|
-
// import Foo from './module' (default import)
|
|
346
|
-
if (node.importClause.name) {
|
|
347
|
-
names.push(node.importClause.name.text)
|
|
348
|
-
isDefault = true
|
|
349
|
-
}
|
|
350
|
-
// import { foo, bar } from './module'
|
|
351
|
-
if (node.importClause.namedBindings) {
|
|
352
|
-
if (ts.isNamedImports(node.importClause.namedBindings)) {
|
|
353
|
-
for (const element of node.importClause.namedBindings.elements) {
|
|
354
|
-
names.push(element.name.text)
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
// import * as foo from './module'
|
|
358
|
-
if (ts.isNamespaceImport(node.importClause.namedBindings)) {
|
|
359
|
-
names.push(node.importClause.namedBindings.name.text)
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Skip type-only imports
|
|
365
|
-
if (node.importClause?.isTypeOnly) return null
|
|
366
|
-
|
|
367
|
-
return {
|
|
368
|
-
source,
|
|
369
|
-
resolvedPath: '', // Filled in by resolver
|
|
370
|
-
names,
|
|
371
|
-
isDefault,
|
|
372
|
-
isDynamic: false,
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** Extract function/method call expressions from a node */
|
|
377
|
-
private extractCalls(node: ts.Node): string[] {
|
|
378
|
-
const calls: string[] = []
|
|
379
|
-
const walkCalls = (n: ts.Node) => {
|
|
380
|
-
if (ts.isCallExpression(n)) {
|
|
381
|
-
const callee = n.expression
|
|
382
|
-
if (ts.isIdentifier(callee)) {
|
|
383
|
-
calls.push(callee.text)
|
|
384
|
-
} else if (ts.isPropertyAccessExpression(callee)) {
|
|
385
|
-
// e.g., obj.method() — we capture the full dotted name
|
|
386
|
-
calls.push(callee.getText(this.sourceFile))
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
ts.forEachChild(n, walkCalls)
|
|
390
|
-
}
|
|
391
|
-
ts.forEachChild(node, walkCalls)
|
|
392
|
-
return [...new Set(calls)] // deduplicate
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/** Extract the purpose from JSDoc comments or preceding single-line comments */
|
|
396
|
-
private extractPurpose(node: ts.Node): string {
|
|
397
|
-
const fullText = this.sourceFile.getFullText()
|
|
398
|
-
const commentRanges = ts.getLeadingCommentRanges(fullText, node.getFullStart())
|
|
399
|
-
if (!commentRanges || commentRanges.length === 0) return ''
|
|
400
|
-
|
|
401
|
-
const meaningfulLines: string[] = []
|
|
402
|
-
for (const range of commentRanges) {
|
|
403
|
-
const comment = fullText.slice(range.pos, range.end)
|
|
404
|
-
let clean = ''
|
|
405
|
-
if (comment.startsWith('/**') || comment.startsWith('/*')) {
|
|
406
|
-
clean = comment.replace(/[\/\*]/g, '').trim()
|
|
407
|
-
} else if (comment.startsWith('//')) {
|
|
408
|
-
clean = comment.replace(/\/\//g, '').trim()
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Skip divider lines (lines with 3+ repeated special characters)
|
|
412
|
-
if (/^[─\-_=\*]{3,}$/.test(clean)) continue
|
|
413
|
-
|
|
414
|
-
if (clean) meaningfulLines.push(clean)
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Return the last few meaningful lines or just the one closest to the node
|
|
418
|
-
// Often the first line of JSDoc or the line right above the node
|
|
419
|
-
return meaningfulLines.length > 0 ? meaningfulLines[meaningfulLines.length - 1].split('\n')[0].trim() : ''
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/** Extract edge cases handled (if statements returning early) */
|
|
423
|
-
private extractEdgeCases(node: ts.Node): string[] {
|
|
424
|
-
const edgeCases: string[] = []
|
|
425
|
-
const walkEdgeCases = (n: ts.Node) => {
|
|
426
|
-
if (ts.isIfStatement(n)) {
|
|
427
|
-
// simple heuristic for early returns inside if blocks
|
|
428
|
-
if (
|
|
429
|
-
ts.isReturnStatement(n.thenStatement) ||
|
|
430
|
-
(ts.isBlock(n.thenStatement) && n.thenStatement.statements.some(ts.isReturnStatement)) ||
|
|
431
|
-
ts.isThrowStatement(n.thenStatement) ||
|
|
432
|
-
(ts.isBlock(n.thenStatement) && n.thenStatement.statements.some(ts.isThrowStatement))
|
|
433
|
-
) {
|
|
434
|
-
edgeCases.push(n.expression.getText(this.sourceFile))
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
ts.forEachChild(n, walkEdgeCases)
|
|
438
|
-
}
|
|
439
|
-
ts.forEachChild(node, walkEdgeCases)
|
|
440
|
-
return edgeCases
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/** Extract try-catch blocks or explicit throw statements */
|
|
444
|
-
private extractErrorHandling(node: ts.Node): { line: number, type: 'try-catch' | 'throw', detail: string }[] {
|
|
445
|
-
const errors: { line: number, type: 'try-catch' | 'throw', detail: string }[] = []
|
|
446
|
-
const walkErrors = (n: ts.Node) => {
|
|
447
|
-
if (ts.isTryStatement(n)) {
|
|
448
|
-
errors.push({
|
|
449
|
-
line: this.getLineNumber(n.getStart()),
|
|
450
|
-
type: 'try-catch',
|
|
451
|
-
detail: 'try-catch block'
|
|
452
|
-
})
|
|
453
|
-
}
|
|
454
|
-
if (ts.isThrowStatement(n)) {
|
|
455
|
-
errors.push({
|
|
456
|
-
line: this.getLineNumber(n.getStart()),
|
|
457
|
-
type: 'throw',
|
|
458
|
-
detail: n.expression ? n.expression.getText(this.sourceFile) : 'throw error'
|
|
459
|
-
})
|
|
460
|
-
}
|
|
461
|
-
ts.forEachChild(n, walkErrors)
|
|
462
|
-
}
|
|
463
|
-
ts.forEachChild(node, walkErrors)
|
|
464
|
-
return errors
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/** Extract detailed line block breakdowns */
|
|
468
|
-
private extractDetailedLines(node: ts.Node): { startLine: number, endLine: number, blockType: string }[] {
|
|
469
|
-
const blocks: { startLine: number, endLine: number, blockType: string }[] = []
|
|
470
|
-
const walkBlocks = (n: ts.Node) => {
|
|
471
|
-
if (ts.isIfStatement(n) || ts.isSwitchStatement(n)) {
|
|
472
|
-
blocks.push({
|
|
473
|
-
startLine: this.getLineNumber(n.getStart()),
|
|
474
|
-
endLine: this.getLineNumber(n.getEnd()),
|
|
475
|
-
blockType: 'ControlFlow'
|
|
476
|
-
})
|
|
477
|
-
} else if (ts.isForStatement(n) || ts.isWhileStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n)) {
|
|
478
|
-
blocks.push({
|
|
479
|
-
startLine: this.getLineNumber(n.getStart()),
|
|
480
|
-
endLine: this.getLineNumber(n.getEnd()),
|
|
481
|
-
blockType: 'Loop'
|
|
482
|
-
})
|
|
483
|
-
} else if (ts.isVariableStatement(n) || ts.isExpressionStatement(n)) {
|
|
484
|
-
// Ignore single lines for brevity unless part of larger logical units
|
|
485
|
-
}
|
|
486
|
-
ts.forEachChild(n, walkBlocks)
|
|
487
|
-
}
|
|
488
|
-
ts.forEachChild(node, walkBlocks)
|
|
489
|
-
return blocks
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/** Extract type parameter names from a generic declaration */
|
|
493
|
-
private extractTypeParameters(typeParams: ts.NodeArray<ts.TypeParameterDeclaration> | undefined): string[] {
|
|
494
|
-
if (!typeParams || typeParams.length === 0) return []
|
|
495
|
-
return typeParams.map(tp => tp.name.text)
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/** Extract decorator names from a class declaration */
|
|
499
|
-
private extractDecorators(node: ts.ClassDeclaration): string[] {
|
|
500
|
-
const decorators: string[] = []
|
|
501
|
-
const modifiers = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined
|
|
502
|
-
if (modifiers) {
|
|
503
|
-
for (const decorator of modifiers) {
|
|
504
|
-
if (ts.isCallExpression(decorator.expression)) {
|
|
505
|
-
// @Injectable() — decorator with arguments
|
|
506
|
-
decorators.push(decorator.expression.expression.getText(this.sourceFile))
|
|
507
|
-
} else if (ts.isIdentifier(decorator.expression)) {
|
|
508
|
-
// @Sealed — decorator without arguments
|
|
509
|
-
decorators.push(decorator.expression.text)
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
return decorators
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/** Extract parameters from a function's parameter list */
|
|
517
|
-
private extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParsedParam[] {
|
|
518
|
-
return params.map((p) => ({
|
|
519
|
-
name: p.name.getText(this.sourceFile),
|
|
520
|
-
type: p.type ? p.type.getText(this.sourceFile) : 'any',
|
|
521
|
-
optional: !!p.questionToken || !!p.initializer,
|
|
522
|
-
defaultValue: p.initializer ? p.initializer.getText(this.sourceFile) : undefined,
|
|
523
|
-
}))
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/** Check if a node has the 'export' modifier */
|
|
527
|
-
private hasExportModifier(node: ts.Node): boolean {
|
|
528
|
-
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
|
|
529
|
-
return !!modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/** Get 1-indexed line number from a character position */
|
|
533
|
-
private getLineNumber(pos: number): number {
|
|
534
|
-
return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/** Walk the top-level children of a node (non-recursive — callbacks decide depth) */
|
|
538
|
-
private walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
|
|
539
|
-
ts.forEachChild(node, (child) => {
|
|
540
|
-
callback(child)
|
|
541
|
-
})
|
|
542
|
-
}
|
|
543
|
-
}
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
import type { ParsedFunction, ParsedClass, ParsedImport, ParsedExport, ParsedParam, ParsedGeneric } from '../types.js'
|
|
3
|
+
import { hashContent } from '../../hash/file-hasher.js'
|
|
4
|
+
|
|
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
|
+
export class TypeScriptExtractor {
|
|
10
|
+
private sourceFile: ts.SourceFile
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private filePath: string,
|
|
14
|
+
private content: string
|
|
15
|
+
) {
|
|
16
|
+
this.sourceFile = ts.createSourceFile(
|
|
17
|
+
filePath,
|
|
18
|
+
content,
|
|
19
|
+
ts.ScriptTarget.Latest,
|
|
20
|
+
true, // setParentNodes
|
|
21
|
+
filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Extract all top-level and variable-assigned functions */
|
|
26
|
+
extractFunctions(): ParsedFunction[] {
|
|
27
|
+
const functions: ParsedFunction[] = []
|
|
28
|
+
this.walkNode(this.sourceFile, (node) => {
|
|
29
|
+
// function declarations: function foo() {}
|
|
30
|
+
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
31
|
+
functions.push(this.parseFunctionDeclaration(node))
|
|
32
|
+
}
|
|
33
|
+
// variable declarations with arrow functions or function expressions:
|
|
34
|
+
// const foo = () => {} or const foo = function() {}
|
|
35
|
+
if (ts.isVariableStatement(node)) {
|
|
36
|
+
for (const decl of node.declarationList.declarations) {
|
|
37
|
+
if (decl.initializer && ts.isIdentifier(decl.name)) {
|
|
38
|
+
if (
|
|
39
|
+
ts.isArrowFunction(decl.initializer) ||
|
|
40
|
+
ts.isFunctionExpression(decl.initializer)
|
|
41
|
+
) {
|
|
42
|
+
functions.push(this.parseVariableFunction(node, decl, decl.initializer))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
return functions
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Extract all class declarations */
|
|
52
|
+
extractClasses(): ParsedClass[] {
|
|
53
|
+
const classes: ParsedClass[] = []
|
|
54
|
+
this.walkNode(this.sourceFile, (node) => {
|
|
55
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
56
|
+
classes.push(this.parseClass(node))
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
return classes
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Extract generic declarations (interfaces, types, constants with metadata) */
|
|
63
|
+
extractGenerics(): ParsedGeneric[] {
|
|
64
|
+
const generics: ParsedGeneric[] = []
|
|
65
|
+
this.walkNode(this.sourceFile, (node) => {
|
|
66
|
+
if (ts.isInterfaceDeclaration(node)) {
|
|
67
|
+
const tp = this.extractTypeParameters(node.typeParameters)
|
|
68
|
+
generics.push({
|
|
69
|
+
id: `intf:${this.filePath}:${node.name.text}`,
|
|
70
|
+
name: node.name.text,
|
|
71
|
+
type: 'interface',
|
|
72
|
+
file: this.filePath,
|
|
73
|
+
startLine: this.getLineNumber(node.getStart()),
|
|
74
|
+
endLine: this.getLineNumber(node.getEnd()),
|
|
75
|
+
isExported: this.hasExportModifier(node),
|
|
76
|
+
...(tp.length > 0 ? { typeParameters: tp } : {}),
|
|
77
|
+
purpose: this.extractPurpose(node),
|
|
78
|
+
})
|
|
79
|
+
} else if (ts.isTypeAliasDeclaration(node)) {
|
|
80
|
+
const tp = this.extractTypeParameters(node.typeParameters)
|
|
81
|
+
generics.push({
|
|
82
|
+
id: `type:${this.filePath}:${node.name.text}`,
|
|
83
|
+
name: node.name.text,
|
|
84
|
+
type: 'type',
|
|
85
|
+
file: this.filePath,
|
|
86
|
+
startLine: this.getLineNumber(node.getStart()),
|
|
87
|
+
endLine: this.getLineNumber(node.getEnd()),
|
|
88
|
+
isExported: this.hasExportModifier(node),
|
|
89
|
+
...(tp.length > 0 ? { typeParameters: tp } : {}),
|
|
90
|
+
purpose: this.extractPurpose(node),
|
|
91
|
+
})
|
|
92
|
+
} else if (ts.isVariableStatement(node) && !this.isVariableFunction(node)) {
|
|
93
|
+
// top-level constants (not functions)
|
|
94
|
+
for (const decl of node.declarationList.declarations) {
|
|
95
|
+
if (ts.isIdentifier(decl.name)) {
|
|
96
|
+
generics.push({
|
|
97
|
+
id: `const:${this.filePath}:${decl.name.text}`,
|
|
98
|
+
name: decl.name.text,
|
|
99
|
+
type: 'const',
|
|
100
|
+
file: this.filePath,
|
|
101
|
+
startLine: this.getLineNumber(node.getStart()),
|
|
102
|
+
endLine: this.getLineNumber(node.getEnd()),
|
|
103
|
+
isExported: this.hasExportModifier(node),
|
|
104
|
+
purpose: this.extractPurpose(node),
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
return generics
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private isVariableFunction(node: ts.VariableStatement): boolean {
|
|
114
|
+
for (const decl of node.declarationList.declarations) {
|
|
115
|
+
if (decl.initializer && (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))) {
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Extract all import statements */
|
|
123
|
+
extractImports(): ParsedImport[] {
|
|
124
|
+
const imports: ParsedImport[] = []
|
|
125
|
+
this.walkNode(this.sourceFile, (node) => {
|
|
126
|
+
if (ts.isImportDeclaration(node)) {
|
|
127
|
+
const parsed = this.parseImport(node)
|
|
128
|
+
if (parsed) imports.push(parsed)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
return imports
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Extract all exported symbols */
|
|
135
|
+
extractExports(): ParsedExport[] {
|
|
136
|
+
const exports: ParsedExport[] = []
|
|
137
|
+
this.walkNode(this.sourceFile, (node) => {
|
|
138
|
+
// export function foo() {}
|
|
139
|
+
if (ts.isFunctionDeclaration(node) && node.name && this.hasExportModifier(node)) {
|
|
140
|
+
exports.push({
|
|
141
|
+
name: node.name.text,
|
|
142
|
+
type: 'function',
|
|
143
|
+
file: this.filePath,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
// export class Foo {}
|
|
147
|
+
if (ts.isClassDeclaration(node) && node.name && this.hasExportModifier(node)) {
|
|
148
|
+
exports.push({
|
|
149
|
+
name: node.name.text,
|
|
150
|
+
type: 'class',
|
|
151
|
+
file: this.filePath,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
// export const foo = ...
|
|
155
|
+
if (ts.isVariableStatement(node) && this.hasExportModifier(node)) {
|
|
156
|
+
for (const decl of node.declarationList.declarations) {
|
|
157
|
+
if (ts.isIdentifier(decl.name)) {
|
|
158
|
+
const type = decl.initializer &&
|
|
159
|
+
(ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))
|
|
160
|
+
? 'function' : 'const'
|
|
161
|
+
exports.push({
|
|
162
|
+
name: decl.name.text,
|
|
163
|
+
type: type as ParsedExport['type'],
|
|
164
|
+
file: this.filePath,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// export interface Foo {}
|
|
170
|
+
if (ts.isInterfaceDeclaration(node) && this.hasExportModifier(node)) {
|
|
171
|
+
exports.push({
|
|
172
|
+
name: node.name.text,
|
|
173
|
+
type: 'interface',
|
|
174
|
+
file: this.filePath,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
// export type Foo = ...
|
|
178
|
+
if (ts.isTypeAliasDeclaration(node) && this.hasExportModifier(node)) {
|
|
179
|
+
exports.push({
|
|
180
|
+
name: node.name.text,
|
|
181
|
+
type: 'type',
|
|
182
|
+
file: this.filePath,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
// export default ...
|
|
186
|
+
if (ts.isExportAssignment(node)) {
|
|
187
|
+
const name = node.expression && ts.isIdentifier(node.expression) ? node.expression.text : 'default'
|
|
188
|
+
exports.push({
|
|
189
|
+
name,
|
|
190
|
+
type: 'default',
|
|
191
|
+
file: this.filePath,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
// export { foo, bar } from './module'
|
|
195
|
+
if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
196
|
+
for (const element of node.exportClause.elements) {
|
|
197
|
+
exports.push({
|
|
198
|
+
name: element.name.text,
|
|
199
|
+
type: 'const',
|
|
200
|
+
file: this.filePath,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
return exports
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Private Helpers ──────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
private parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
|
|
211
|
+
const name = node.name!.text
|
|
212
|
+
const startLine = this.getLineNumber(node.getStart())
|
|
213
|
+
const endLine = this.getLineNumber(node.getEnd())
|
|
214
|
+
const params = this.extractParams(node.parameters)
|
|
215
|
+
const returnType = node.type ? node.type.getText(this.sourceFile) : 'void'
|
|
216
|
+
const isAsync = !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
217
|
+
const isGenerator = !!node.asteriskToken
|
|
218
|
+
const typeParameters = this.extractTypeParameters(node.typeParameters)
|
|
219
|
+
const calls = this.extractCalls(node)
|
|
220
|
+
const bodyText = node.getText(this.sourceFile)
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
id: `fn:${this.filePath}:${name}`,
|
|
224
|
+
name,
|
|
225
|
+
file: this.filePath,
|
|
226
|
+
startLine,
|
|
227
|
+
endLine,
|
|
228
|
+
params,
|
|
229
|
+
returnType,
|
|
230
|
+
isExported: this.hasExportModifier(node),
|
|
231
|
+
isAsync,
|
|
232
|
+
...(isGenerator ? { isGenerator } : {}),
|
|
233
|
+
...(typeParameters.length > 0 ? { typeParameters } : {}),
|
|
234
|
+
calls,
|
|
235
|
+
hash: hashContent(bodyText),
|
|
236
|
+
purpose: this.extractPurpose(node),
|
|
237
|
+
edgeCasesHandled: this.extractEdgeCases(node),
|
|
238
|
+
errorHandling: this.extractErrorHandling(node),
|
|
239
|
+
detailedLines: this.extractDetailedLines(node),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private parseVariableFunction(
|
|
244
|
+
stmt: ts.VariableStatement,
|
|
245
|
+
decl: ts.VariableDeclaration,
|
|
246
|
+
fn: ts.ArrowFunction | ts.FunctionExpression
|
|
247
|
+
): ParsedFunction {
|
|
248
|
+
const name = (decl.name as ts.Identifier).text
|
|
249
|
+
const startLine = this.getLineNumber(stmt.getStart())
|
|
250
|
+
const endLine = this.getLineNumber(stmt.getEnd())
|
|
251
|
+
const params = this.extractParams(fn.parameters)
|
|
252
|
+
const returnType = fn.type ? fn.type.getText(this.sourceFile) : 'void'
|
|
253
|
+
const isAsync = !!fn.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
254
|
+
const isGenerator = ts.isFunctionExpression(fn) && !!fn.asteriskToken
|
|
255
|
+
const typeParameters = this.extractTypeParameters(fn.typeParameters)
|
|
256
|
+
const calls = this.extractCalls(fn)
|
|
257
|
+
const bodyText = stmt.getText(this.sourceFile)
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
id: `fn:${this.filePath}:${name}`,
|
|
261
|
+
name,
|
|
262
|
+
file: this.filePath,
|
|
263
|
+
startLine,
|
|
264
|
+
endLine,
|
|
265
|
+
params,
|
|
266
|
+
returnType,
|
|
267
|
+
isExported: this.hasExportModifier(stmt),
|
|
268
|
+
isAsync,
|
|
269
|
+
...(isGenerator ? { isGenerator } : {}),
|
|
270
|
+
...(typeParameters.length > 0 ? { typeParameters } : {}),
|
|
271
|
+
calls,
|
|
272
|
+
hash: hashContent(bodyText),
|
|
273
|
+
purpose: this.extractPurpose(stmt),
|
|
274
|
+
edgeCasesHandled: this.extractEdgeCases(fn),
|
|
275
|
+
errorHandling: this.extractErrorHandling(fn),
|
|
276
|
+
detailedLines: this.extractDetailedLines(fn),
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private parseClass(node: ts.ClassDeclaration): ParsedClass {
|
|
281
|
+
const name = node.name!.text
|
|
282
|
+
const startLine = this.getLineNumber(node.getStart())
|
|
283
|
+
const endLine = this.getLineNumber(node.getEnd())
|
|
284
|
+
const methods: ParsedFunction[] = []
|
|
285
|
+
const decorators = this.extractDecorators(node)
|
|
286
|
+
const typeParameters = this.extractTypeParameters(node.typeParameters)
|
|
287
|
+
|
|
288
|
+
for (const member of node.members) {
|
|
289
|
+
if (ts.isMethodDeclaration(member) && member.name) {
|
|
290
|
+
const methodName = member.name.getText(this.sourceFile)
|
|
291
|
+
const mStartLine = this.getLineNumber(member.getStart())
|
|
292
|
+
const mEndLine = this.getLineNumber(member.getEnd())
|
|
293
|
+
const params = this.extractParams(member.parameters)
|
|
294
|
+
const returnType = member.type ? member.type.getText(this.sourceFile) : 'void'
|
|
295
|
+
const isAsync = !!member.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
296
|
+
const isGenerator = !!member.asteriskToken
|
|
297
|
+
const methodTypeParams = this.extractTypeParameters(member.typeParameters)
|
|
298
|
+
const calls = this.extractCalls(member)
|
|
299
|
+
const bodyText = member.getText(this.sourceFile)
|
|
300
|
+
|
|
301
|
+
methods.push({
|
|
302
|
+
id: `fn:${this.filePath}:${name}.${methodName}`,
|
|
303
|
+
name: `${name}.${methodName}`,
|
|
304
|
+
file: this.filePath,
|
|
305
|
+
startLine: mStartLine,
|
|
306
|
+
endLine: mEndLine,
|
|
307
|
+
params,
|
|
308
|
+
returnType,
|
|
309
|
+
isExported: this.hasExportModifier(node),
|
|
310
|
+
isAsync,
|
|
311
|
+
...(isGenerator ? { isGenerator } : {}),
|
|
312
|
+
...(methodTypeParams.length > 0 ? { typeParameters: methodTypeParams } : {}),
|
|
313
|
+
calls,
|
|
314
|
+
hash: hashContent(bodyText),
|
|
315
|
+
purpose: this.extractPurpose(member),
|
|
316
|
+
edgeCasesHandled: this.extractEdgeCases(member),
|
|
317
|
+
errorHandling: this.extractErrorHandling(member),
|
|
318
|
+
detailedLines: this.extractDetailedLines(member),
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
id: `class:${this.filePath}:${name}`,
|
|
325
|
+
name,
|
|
326
|
+
file: this.filePath,
|
|
327
|
+
startLine,
|
|
328
|
+
endLine,
|
|
329
|
+
methods,
|
|
330
|
+
isExported: this.hasExportModifier(node),
|
|
331
|
+
...(decorators.length > 0 ? { decorators } : {}),
|
|
332
|
+
...(typeParameters.length > 0 ? { typeParameters } : {}),
|
|
333
|
+
purpose: this.extractPurpose(node),
|
|
334
|
+
edgeCasesHandled: this.extractEdgeCases(node),
|
|
335
|
+
errorHandling: this.extractErrorHandling(node),
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private parseImport(node: ts.ImportDeclaration): ParsedImport | null {
|
|
340
|
+
const source = (node.moduleSpecifier as ts.StringLiteral).text
|
|
341
|
+
const names: string[] = []
|
|
342
|
+
let isDefault = false
|
|
343
|
+
|
|
344
|
+
if (node.importClause) {
|
|
345
|
+
// import Foo from './module' (default import)
|
|
346
|
+
if (node.importClause.name) {
|
|
347
|
+
names.push(node.importClause.name.text)
|
|
348
|
+
isDefault = true
|
|
349
|
+
}
|
|
350
|
+
// import { foo, bar } from './module'
|
|
351
|
+
if (node.importClause.namedBindings) {
|
|
352
|
+
if (ts.isNamedImports(node.importClause.namedBindings)) {
|
|
353
|
+
for (const element of node.importClause.namedBindings.elements) {
|
|
354
|
+
names.push(element.name.text)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// import * as foo from './module'
|
|
358
|
+
if (ts.isNamespaceImport(node.importClause.namedBindings)) {
|
|
359
|
+
names.push(node.importClause.namedBindings.name.text)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Skip type-only imports
|
|
365
|
+
if (node.importClause?.isTypeOnly) return null
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
source,
|
|
369
|
+
resolvedPath: '', // Filled in by resolver
|
|
370
|
+
names,
|
|
371
|
+
isDefault,
|
|
372
|
+
isDynamic: false,
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Extract function/method call expressions from a node */
|
|
377
|
+
private extractCalls(node: ts.Node): string[] {
|
|
378
|
+
const calls: string[] = []
|
|
379
|
+
const walkCalls = (n: ts.Node) => {
|
|
380
|
+
if (ts.isCallExpression(n)) {
|
|
381
|
+
const callee = n.expression
|
|
382
|
+
if (ts.isIdentifier(callee)) {
|
|
383
|
+
calls.push(callee.text)
|
|
384
|
+
} else if (ts.isPropertyAccessExpression(callee)) {
|
|
385
|
+
// e.g., obj.method() — we capture the full dotted name
|
|
386
|
+
calls.push(callee.getText(this.sourceFile))
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
ts.forEachChild(n, walkCalls)
|
|
390
|
+
}
|
|
391
|
+
ts.forEachChild(node, walkCalls)
|
|
392
|
+
return [...new Set(calls)] // deduplicate
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Extract the purpose from JSDoc comments or preceding single-line comments */
|
|
396
|
+
private extractPurpose(node: ts.Node): string {
|
|
397
|
+
const fullText = this.sourceFile.getFullText()
|
|
398
|
+
const commentRanges = ts.getLeadingCommentRanges(fullText, node.getFullStart())
|
|
399
|
+
if (!commentRanges || commentRanges.length === 0) return ''
|
|
400
|
+
|
|
401
|
+
const meaningfulLines: string[] = []
|
|
402
|
+
for (const range of commentRanges) {
|
|
403
|
+
const comment = fullText.slice(range.pos, range.end)
|
|
404
|
+
let clean = ''
|
|
405
|
+
if (comment.startsWith('/**') || comment.startsWith('/*')) {
|
|
406
|
+
clean = comment.replace(/[\/\*]/g, '').trim()
|
|
407
|
+
} else if (comment.startsWith('//')) {
|
|
408
|
+
clean = comment.replace(/\/\//g, '').trim()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Skip divider lines (lines with 3+ repeated special characters)
|
|
412
|
+
if (/^[─\-_=\*]{3,}$/.test(clean)) continue
|
|
413
|
+
|
|
414
|
+
if (clean) meaningfulLines.push(clean)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Return the last few meaningful lines or just the one closest to the node
|
|
418
|
+
// Often the first line of JSDoc or the line right above the node
|
|
419
|
+
return meaningfulLines.length > 0 ? meaningfulLines[meaningfulLines.length - 1].split('\n')[0].trim() : ''
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Extract edge cases handled (if statements returning early) */
|
|
423
|
+
private extractEdgeCases(node: ts.Node): string[] {
|
|
424
|
+
const edgeCases: string[] = []
|
|
425
|
+
const walkEdgeCases = (n: ts.Node) => {
|
|
426
|
+
if (ts.isIfStatement(n)) {
|
|
427
|
+
// simple heuristic for early returns inside if blocks
|
|
428
|
+
if (
|
|
429
|
+
ts.isReturnStatement(n.thenStatement) ||
|
|
430
|
+
(ts.isBlock(n.thenStatement) && n.thenStatement.statements.some(ts.isReturnStatement)) ||
|
|
431
|
+
ts.isThrowStatement(n.thenStatement) ||
|
|
432
|
+
(ts.isBlock(n.thenStatement) && n.thenStatement.statements.some(ts.isThrowStatement))
|
|
433
|
+
) {
|
|
434
|
+
edgeCases.push(n.expression.getText(this.sourceFile))
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
ts.forEachChild(n, walkEdgeCases)
|
|
438
|
+
}
|
|
439
|
+
ts.forEachChild(node, walkEdgeCases)
|
|
440
|
+
return edgeCases
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Extract try-catch blocks or explicit throw statements */
|
|
444
|
+
private extractErrorHandling(node: ts.Node): { line: number, type: 'try-catch' | 'throw', detail: string }[] {
|
|
445
|
+
const errors: { line: number, type: 'try-catch' | 'throw', detail: string }[] = []
|
|
446
|
+
const walkErrors = (n: ts.Node) => {
|
|
447
|
+
if (ts.isTryStatement(n)) {
|
|
448
|
+
errors.push({
|
|
449
|
+
line: this.getLineNumber(n.getStart()),
|
|
450
|
+
type: 'try-catch',
|
|
451
|
+
detail: 'try-catch block'
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
if (ts.isThrowStatement(n)) {
|
|
455
|
+
errors.push({
|
|
456
|
+
line: this.getLineNumber(n.getStart()),
|
|
457
|
+
type: 'throw',
|
|
458
|
+
detail: n.expression ? n.expression.getText(this.sourceFile) : 'throw error'
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
ts.forEachChild(n, walkErrors)
|
|
462
|
+
}
|
|
463
|
+
ts.forEachChild(node, walkErrors)
|
|
464
|
+
return errors
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Extract detailed line block breakdowns */
|
|
468
|
+
private extractDetailedLines(node: ts.Node): { startLine: number, endLine: number, blockType: string }[] {
|
|
469
|
+
const blocks: { startLine: number, endLine: number, blockType: string }[] = []
|
|
470
|
+
const walkBlocks = (n: ts.Node) => {
|
|
471
|
+
if (ts.isIfStatement(n) || ts.isSwitchStatement(n)) {
|
|
472
|
+
blocks.push({
|
|
473
|
+
startLine: this.getLineNumber(n.getStart()),
|
|
474
|
+
endLine: this.getLineNumber(n.getEnd()),
|
|
475
|
+
blockType: 'ControlFlow'
|
|
476
|
+
})
|
|
477
|
+
} else if (ts.isForStatement(n) || ts.isWhileStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n)) {
|
|
478
|
+
blocks.push({
|
|
479
|
+
startLine: this.getLineNumber(n.getStart()),
|
|
480
|
+
endLine: this.getLineNumber(n.getEnd()),
|
|
481
|
+
blockType: 'Loop'
|
|
482
|
+
})
|
|
483
|
+
} else if (ts.isVariableStatement(n) || ts.isExpressionStatement(n)) {
|
|
484
|
+
// Ignore single lines for brevity unless part of larger logical units
|
|
485
|
+
}
|
|
486
|
+
ts.forEachChild(n, walkBlocks)
|
|
487
|
+
}
|
|
488
|
+
ts.forEachChild(node, walkBlocks)
|
|
489
|
+
return blocks
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Extract type parameter names from a generic declaration */
|
|
493
|
+
private extractTypeParameters(typeParams: ts.NodeArray<ts.TypeParameterDeclaration> | undefined): string[] {
|
|
494
|
+
if (!typeParams || typeParams.length === 0) return []
|
|
495
|
+
return typeParams.map(tp => tp.name.text)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Extract decorator names from a class declaration */
|
|
499
|
+
private extractDecorators(node: ts.ClassDeclaration): string[] {
|
|
500
|
+
const decorators: string[] = []
|
|
501
|
+
const modifiers = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined
|
|
502
|
+
if (modifiers) {
|
|
503
|
+
for (const decorator of modifiers) {
|
|
504
|
+
if (ts.isCallExpression(decorator.expression)) {
|
|
505
|
+
// @Injectable() — decorator with arguments
|
|
506
|
+
decorators.push(decorator.expression.expression.getText(this.sourceFile))
|
|
507
|
+
} else if (ts.isIdentifier(decorator.expression)) {
|
|
508
|
+
// @Sealed — decorator without arguments
|
|
509
|
+
decorators.push(decorator.expression.text)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return decorators
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Extract parameters from a function's parameter list */
|
|
517
|
+
private extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParsedParam[] {
|
|
518
|
+
return params.map((p) => ({
|
|
519
|
+
name: p.name.getText(this.sourceFile),
|
|
520
|
+
type: p.type ? p.type.getText(this.sourceFile) : 'any',
|
|
521
|
+
optional: !!p.questionToken || !!p.initializer,
|
|
522
|
+
defaultValue: p.initializer ? p.initializer.getText(this.sourceFile) : undefined,
|
|
523
|
+
}))
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Check if a node has the 'export' modifier */
|
|
527
|
+
private hasExportModifier(node: ts.Node): boolean {
|
|
528
|
+
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
|
|
529
|
+
return !!modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Get 1-indexed line number from a character position */
|
|
533
|
+
private getLineNumber(pos: number): number {
|
|
534
|
+
return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Walk the top-level children of a node (non-recursive — callbacks decide depth) */
|
|
538
|
+
private walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
|
|
539
|
+
ts.forEachChild(node, (child) => {
|
|
540
|
+
callback(child)
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
}
|