@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.
Files changed (44) hide show
  1. package/README.md +431 -0
  2. package/package.json +6 -2
  3. package/src/contract/contract-generator.ts +85 -85
  4. package/src/contract/contract-reader.ts +28 -28
  5. package/src/contract/contract-writer.ts +114 -114
  6. package/src/contract/index.ts +12 -12
  7. package/src/contract/lock-compiler.ts +221 -221
  8. package/src/contract/lock-reader.ts +34 -34
  9. package/src/contract/schema.ts +147 -147
  10. package/src/graph/cluster-detector.ts +312 -312
  11. package/src/graph/graph-builder.ts +211 -211
  12. package/src/graph/impact-analyzer.ts +55 -55
  13. package/src/graph/index.ts +4 -4
  14. package/src/graph/types.ts +59 -59
  15. package/src/hash/file-hasher.ts +30 -30
  16. package/src/hash/hash-store.ts +119 -119
  17. package/src/hash/index.ts +3 -3
  18. package/src/hash/tree-hasher.ts +20 -20
  19. package/src/index.ts +12 -12
  20. package/src/parser/base-parser.ts +16 -16
  21. package/src/parser/boundary-checker.ts +211 -211
  22. package/src/parser/index.ts +46 -46
  23. package/src/parser/types.ts +90 -90
  24. package/src/parser/typescript/ts-extractor.ts +543 -543
  25. package/src/parser/typescript/ts-parser.ts +41 -41
  26. package/src/parser/typescript/ts-resolver.ts +86 -86
  27. package/src/utils/errors.ts +42 -42
  28. package/src/utils/fs.ts +75 -75
  29. package/src/utils/fuzzy-match.ts +186 -186
  30. package/src/utils/logger.ts +36 -36
  31. package/src/utils/minimatch.ts +19 -19
  32. package/tests/contract.test.ts +134 -134
  33. package/tests/fixtures/simple-api/package.json +5 -5
  34. package/tests/fixtures/simple-api/src/auth/middleware.ts +9 -9
  35. package/tests/fixtures/simple-api/src/auth/verify.ts +6 -6
  36. package/tests/fixtures/simple-api/src/index.ts +9 -9
  37. package/tests/fixtures/simple-api/src/utils/jwt.ts +3 -3
  38. package/tests/fixtures/simple-api/tsconfig.json +8 -8
  39. package/tests/fuzzy-match.test.ts +142 -142
  40. package/tests/graph.test.ts +169 -169
  41. package/tests/hash.test.ts +49 -49
  42. package/tests/helpers.ts +83 -83
  43. package/tests/parser.test.ts +218 -218
  44. 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
+ }